diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-04-28 02:13:30 +0000 |
| commit | ef4c533ebacc2cdc97e518f30e9a9350004fcdfb (patch) | |
| tree | 345251a3ed0f4429716fa5edaa31024d8f4cb560 /lib | |
| parent | 9ceed79cf32c896f8a998399bf1b296506b2cd4a (diff) | |
~20250428 작업사항
Diffstat (limited to 'lib')
186 files changed, 22891 insertions, 3135 deletions
diff --git a/lib/admin-users/service.ts b/lib/admin-users/service.ts index 5d738d38..44111bef 100644 --- a/lib/admin-users/service.ts +++ b/lib/admin-users/service.ts @@ -7,6 +7,7 @@ import logger from '@/lib/logger'; import { Role, roles, users, userView, type User, type UserView } from "@/db/schema/users"; // User 테이블 import { type Company } from "@/db/schema/companies"; // User 테이블 import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { headers } from 'next/headers'; // 레포지토리 함수들 (예시) - 아래처럼 작성했다고 가정 import { @@ -196,7 +197,11 @@ export async function createAdminUser(input: CreateUserSchema & { language?: str ? "[eVCP] 어드민 계정이 생성되었습니다." : "[eVCP] Admin Account Created"; - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + const baseUrl = `http://${host}` const loginUrl = userLang === "ko" ? `${baseUrl}/ko/partners` diff --git a/lib/basic-contract/repository.ts b/lib/basic-contract/repository.ts new file mode 100644 index 00000000..aab70106 --- /dev/null +++ b/lib/basic-contract/repository.ts @@ -0,0 +1,167 @@ +"use server";
+
+import { asc, count,inArray ,eq} from "drizzle-orm";
+import { basicContractTemplates, basicContractView, type BasicContractTemplate } from "@/db/schema";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import db from "@/db/db";
+
+// 템플릿 목록 조회
+export async function selectBasicContractTemplates(
+ tx: PgTransaction<any, any, any>,
+ options: {
+ where?: any;
+ orderBy?: any[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset, limit } = options;
+
+ return tx
+ .select()
+ .from(basicContractTemplates)
+ .where(where || undefined)
+ .orderBy(...(orderBy || [asc(basicContractTemplates.createdAt)]))
+ .offset(offset || 0)
+ .limit(limit || 50);
+}
+
+export async function selectBasicContracts(
+ tx: PgTransaction<any, any, any>,
+ options: {
+ where?: any;
+ orderBy?: any[];
+ offset?: number;
+ limit?: number;
+ }
+) {
+ const { where, orderBy, offset, limit } = options;
+
+ return tx
+ .select()
+ .from(basicContractView)
+ .where(where || undefined)
+ .orderBy(...(orderBy || [asc(basicContractView.createdAt)]))
+ .offset(offset || 0)
+ .limit(limit || 50);
+}
+
+// 템플릿 개수 조회
+export async function countBasicContractTemplates(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const result = await tx
+ .select({ count: count() })
+ .from(basicContractTemplates)
+ .where(where || undefined);
+
+ return result[0]?.count || 0;
+}
+
+export async function countBasicContracts(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const result = await tx
+ .select({ count: count() })
+ .from(basicContractView)
+ .where(where || undefined);
+
+ return result[0]?.count || 0;
+}
+
+
+// 템플릿 생성
+export async function insertBasicContractTemplate(
+ tx: PgTransaction<any, any, any>,
+ data: Omit<BasicContractTemplate, "id" | "createdAt" | "updatedAt">
+) {
+ return tx
+ .insert(basicContractTemplates)
+ .values({
+ ...data,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+}
+
+/**
+ * ID로 특정 기본 계약서 템플릿을 조회합니다.
+ * @param tx 데이터베이스 트랜잭션
+ * @param id 조회할 템플릿 ID (문자열로 받아서 숫자로 변환)
+ * @returns 조회된 템플릿 또는 에러가 있는 경우 null
+ */
+export async function getBasicContractTemplateById(
+ tx: PgTransaction<any, any, any>,
+ id: number
+): Promise<{ data: BasicContractTemplate | null; error: string | null }> {
+ try {
+
+ const templates = await tx
+ .select()
+ .from(basicContractTemplates)
+ .where(eq(basicContractTemplates.id, id));
+
+ if (!templates || templates.length === 0) {
+ return { data: null, error: null };
+ }
+
+ return { data: templates[0], error: null };
+ } catch (error) {
+ console.error(`템플릿 조회 중 오류 발생 (ID: ${id}):`, error);
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "템플릿 조회 중 오류가 발생했습니다."
+ };
+ }
+}
+
+/**
+ * 여러 기본 계약서 템플릿을 ID 배열 기반으로 삭제합니다.
+ * @param tx 데이터베이스 트랜잭션
+ * @param ids 삭제할 템플릿 ID 배열 (문자열로 받아서 숫자로 변환)
+ * @returns 삭제된 템플릿 배열 또는 에러 정보
+ */
+export async function deleteBasicContractTemplates(
+ tx: PgTransaction<any, any, any>,
+ ids: number[]
+): Promise<{ data: BasicContractTemplate[] | null; error: string | null }> {
+ if (!ids || ids.length === 0) {
+ return { data: [], error: null };
+ }
+
+ try {
+
+
+ // 삭제될 템플릿 정보를 반환하기 위해 먼저 조회
+ const templatesBeforeDelete = await tx
+ .select()
+ .from(basicContractTemplates)
+ .where(inArray(basicContractTemplates.id, ids));
+
+ // 삭제 실행
+ const deletedTemplates = await tx
+ .delete(basicContractTemplates)
+ .where(inArray(basicContractTemplates.id, ids))
+ .returning();
+
+ return {
+ data: templatesBeforeDelete.length > 0 ? templatesBeforeDelete : deletedTemplates,
+ error: null
+ };
+ } catch (error) {
+ console.error("템플릿 삭제 중 오류 발생:", error);
+ return {
+ data: null,
+ error: error instanceof Error ? error.message : "템플릿 삭제 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+
+export async function findAllTemplates(): Promise<BasicContractTemplate[]> {
+ return db.select().from(basicContractTemplates).orderBy(asc(basicContractTemplates.id));
+}
\ No newline at end of file diff --git a/lib/basic-contract/service.ts b/lib/basic-contract/service.ts new file mode 100644 index 00000000..09f8f119 --- /dev/null +++ b/lib/basic-contract/service.ts @@ -0,0 +1,957 @@ +"use server";
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { getErrorMessage } from "@/lib/handle-error";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm";
+import { v4 as uuidv4 } from "uuid";
+import {
+ basicContract,
+ BasicContractTemplate,
+ basicContractTemplates,
+ basicContractView,
+ vendors,
+ type BasicContractTemplate as DBBasicContractTemplate,
+} from "@/db/schema";
+import { toast } from "sonner";
+import { promises as fs } from "fs";
+import path from "path";
+import crypto from "crypto";
+
+import {
+ GetBasicContractTemplatesSchema,
+ CreateBasicContractTemplateSchema,
+ GetBasciContractsSchema,
+} from "./validations";
+
+import {
+ insertBasicContractTemplate,
+ selectBasicContractTemplates,
+ countBasicContractTemplates,
+ deleteBasicContractTemplates,
+ getBasicContractTemplateById,
+ selectBasicContracts,
+ countBasicContracts,
+ findAllTemplates
+} from "./repository";
+import { revalidatePath } from 'next/cache';
+import { sendEmail } from "../mail/sendEmail";
+import { headers } from 'next/headers';
+import { filterColumns } from "@/lib/filter-columns";
+import { differenceInDays, addYears, isBefore } from "date-fns";
+
+
+
+// 템플릿 추가
+export async function addTemplate(
+ templateData: FormData | Omit<BasicContractTemplate, "id" | "createdAt" | "updatedAt">
+): Promise<{ success: boolean; data?: BasicContractTemplate; error?: string }> {
+ try {
+ // FormData인 경우 파일 추출 및 저장 처리
+ if (templateData instanceof FormData) {
+ const templateName = templateData.get("templateName") as string;
+ // 문자열을 숫자로 변환 (FormData의 get은 항상 string|File을 반환)
+ const validityPeriodStr = templateData.get("validityPeriod") as string;
+ const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12; // 기본값 12개월
+ const status = templateData.get("status") as "ACTIVE" | "INACTIVE";
+ const file = templateData.get("file") as File;
+
+ // 유효성 검사
+ if (!templateName) {
+ return { success: false, error: "템플릿 이름은 필수입니다." };
+ }
+
+ if (!file) {
+ return { success: false, error: "파일은 필수입니다." };
+ }
+
+ if (isNaN(validityPeriod) || validityPeriod < 1 || validityPeriod > 120) {
+ return {
+ success: false,
+ error: "유효기간은 1~120개월 사이의 유효한 값이어야 합니다."
+ };
+ }
+
+ // 원본 파일 이름과 확장자 분리
+ const originalFileName = file.name;
+ const fileExtension = path.extname(originalFileName);
+ const fileNameWithoutExt = path.basename(originalFileName, fileExtension);
+
+ // 해시된 파일 이름 생성 (타임스탬프 + 랜덤 해시 + 확장자)
+ const timestamp = Date.now();
+ const randomHash = crypto.createHash('md5')
+ .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`)
+ .digest('hex')
+ .substring(0, 8);
+
+ const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`;
+
+ // 저장 디렉토리 설정 (uploads/contracts 폴더 사용)
+ const uploadDir = path.join(process.cwd(), "public", "basicContract", "template");
+
+ // 디렉토리가 없으면 생성
+ try {
+ await fs.mkdir(uploadDir, { recursive: true });
+ } catch (err) {
+ console.log("Directory already exists or creation failed:", err);
+ }
+
+ // 파일 경로 설정
+ const filePath = path.join(uploadDir, hashedFileName);
+ const publicFilePath = `/basicContract/template/${hashedFileName}`;
+
+ // 파일을 ArrayBuffer로 변환
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // 파일 저장
+ await fs.writeFile(filePath, buffer);
+
+ // DB에 저장할 데이터 구성
+ const formattedData = {
+ templateName,
+ status,
+ validityPeriod, // 숫자로 변환된 유효기간
+ fileName: originalFileName, // 원본 파일 이름
+ filePath: publicFilePath, // 공개 접근 가능한 경로
+ };
+
+ // DB에 저장
+ const { data, error } = await createBasicContractTemplate(formattedData);
+
+ if (error) {
+ // 파일 저장 후 DB 저장 실패 시 저장된 파일 삭제
+ try {
+ await fs.unlink(filePath);
+ } catch (unlinkError) {
+ console.error("파일 삭제 실패:", unlinkError);
+ }
+ return { success: false, error };
+ }
+
+ return { success: true, data: data || undefined };
+
+ }
+ // 기존 객체 형태인 경우 (호환성 유지)
+ else {
+ const formattedData = {
+ ...templateData,
+ status: templateData.status as "ACTIVE" | "INACTIVE",
+ // validityPeriod가 없으면 기본값 12개월 사용
+ validityPeriod: templateData.validityPeriod || 12,
+ };
+
+ const { data, error } = await createBasicContractTemplate(formattedData);
+
+ if (error) {
+ return { success: false, error };
+ }
+
+ return { success: true, data: data || undefined };
+
+ }
+ } catch (error) {
+ console.error("Template add error:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "템플릿 추가 중 오류가 발생했습니다."
+ };
+ }
+}
+// 기본 계약서 템플릿 목록 조회 (서버 액션)
+export async function getBasicContractTemplates(
+ input: GetBasicContractTemplatesSchema
+) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ const { data, total } = await db.transaction(async (tx) => {
+ // 간소화된 구현 - 실제 filterColumns 함수는 더 복잡할 수 있습니다
+ let whereCondition = undefined;
+
+ if (input.search) {
+ const s = `%${input.search}%`;
+ whereCondition = or(
+ ilike(basicContractTemplates.templateName, s),
+ ilike(basicContractTemplates.fileName, s),
+ ilike(basicContractTemplates.status, s)
+ );
+ }
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ basicContractTemplates[
+ item.id as keyof typeof basicContractTemplates
+ ] as any
+ )
+ : asc(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ basicContractTemplates[
+ item.id as keyof typeof basicContractTemplates
+ ] as any
+ )
+ )
+ : [desc(basicContractTemplates.createdAt)];
+
+ const dataResult = await selectBasicContractTemplates(tx, {
+ where: whereCondition,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+
+ const totalCount = await countBasicContractTemplates(
+ tx,
+ whereCondition
+ );
+ return { data: dataResult, total: totalCount };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (error) {
+ console.error("getBasicContractTemplates 에러:", error);
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["basic-contract-templates"],
+ }
+ )();
+}
+
+
+// 템플릿 생성 (서버 액션)
+export async function createBasicContractTemplate(
+ input: CreateBasicContractTemplateSchema
+) {
+ unstable_noStore();
+ try {
+ const newTemplate = await db.transaction(async (tx) => {
+ const [newTemplate] = await insertBasicContractTemplate(tx, {
+ templateName: input.templateName,
+ validityPeriod: input.validityPeriod,
+ status: input.status,
+ fileName: input.fileName,
+ filePath: input.filePath,
+ });
+ return newTemplate;
+ });
+
+ // 캐시 무효화
+ revalidateTag("basic-contract-templates");
+ revalidateTag("template-status-counts");
+
+ return { data: newTemplate, error: null };
+ } catch (error) {
+ return { data: null, error: getErrorMessage(error) };
+ }
+}
+
+//서명 계약서 저장, 김기만 프로님 추가 코드
+export const saveSignedContract = async (
+ fileBuffer: ArrayBuffer,
+ templateName: string,
+ tableRowId: number
+): Promise<{ result: true } | { result: false; error: string }> => {
+ try {
+ const originalName = `${tableRowId}_${templateName}`;
+ const ext = path.extname(originalName);
+ const uniqueName = uuidv4() + ext;
+
+ const publicDir = path.join(process.cwd(), "public", "basicContract");
+ const relativePath = `/basicContract/${uniqueName}`;
+ const absolutePath = path.join(publicDir, uniqueName);
+ const buffer = Buffer.from(fileBuffer);
+
+ await fs.mkdir(publicDir, { recursive: true });
+ await fs.writeFile(absolutePath, buffer);
+
+ await db.transaction(async (tx) => {
+ await tx
+ .update(basicContract)
+ .set({
+ status: "COMPLETED",
+ fileName: originalName,
+ filePath: relativePath,
+ })
+ .where(eq(basicContract.id, tableRowId));
+ });
+ // 캐시 무효화
+ revalidateTag("basic-contract-requests");
+ revalidateTag("template-status-counts");
+
+ return { result: true };
+ } catch (err: unknown) {
+ const errorMessage = err instanceof Error ? err.message : "알 수 없는 오류";
+ return { result: false, error: errorMessage };
+ }
+};
+
+interface RemoveTemplatesProps {
+ ids: number[];
+}
+
+
+interface TemplateFile {
+ id: number;
+ filePath: string;
+}
+
+export async function removeTemplates({
+ ids
+}: RemoveTemplatesProps): Promise<{ success?: boolean; error?: string }> {
+ if (!ids || ids.length === 0) {
+ return { error: "삭제할 템플릿이 선택되지 않았습니다." };
+ }
+
+ // unstable_noStore를 최상단에 배치
+ unstable_noStore();
+
+ try {
+ // 파일 삭제를 위한 템플릿 정보 조회 및 DB 삭제를 직접 트랜잭션으로 처리
+ // withTransaction 대신 db.transaction 직접 사용 (createBasicContractTemplate와 일관성 유지)
+ const templateFiles: TemplateFile[] = [];
+
+ const result = await db.transaction(async (tx) => {
+ // 각 템플릿의 파일 경로 가져오기
+ for (const id of ids) {
+ const { data: template, error } = await getBasicContractTemplateById(tx, id);
+ if (template && template.filePath) {
+ templateFiles.push({
+ id: template.id,
+ filePath: template.filePath
+ });
+ }
+ }
+
+ // DB에서 템플릿 삭제
+ const { data, error } = await deleteBasicContractTemplates(tx, ids);
+
+ if (error) {
+ throw new Error(`템플릿 DB 삭제 실패: ${error}`);
+ }
+
+ return { data };
+ });
+
+ // 파일 시스템 삭제는 트랜잭션 성공 후 수행
+ for (const template of templateFiles) {
+ if (template.filePath) {
+ const absoluteFilePath = path.join(process.cwd(), 'public', template.filePath);
+
+ try {
+ await fs.access(absoluteFilePath);
+ await fs.unlink(absoluteFilePath);
+ } catch (fileError) {
+ console.log(`파일 없음 또는 삭제 실패: ${template.filePath}`, fileError);
+ // 파일 삭제 실패는 전체 작업 성공에 영향 없음
+ }
+ }
+ }
+ revalidateTag("basic-contract-templates");
+ revalidateTag("template-status-counts");
+
+
+
+ // 디버깅을 위한 로그
+ console.log("캐시 무효화 완료:", ids);
+
+ return { success: true };
+ } catch (error) {
+ console.error("템플릿 삭제 중 오류 발생:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "템플릿 삭제 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+interface UpdateTemplateParams {
+ id: number;
+ formData: FormData;
+}
+
+export async function updateTemplate({
+ id,
+ formData
+}: UpdateTemplateParams): Promise<{ success?: boolean; error?: string }> {
+ unstable_noStore();
+
+ try {
+ const templateName = formData.get("templateName") as string;
+ const validityPeriodStr = formData.get("validityPeriod") as string;
+ const validityPeriod = validityPeriodStr ? parseInt(validityPeriodStr, 10) : 12;
+ const status = formData.get("status") as "ACTIVE" | "INACTIVE";
+ const file = formData.get("file") as File | null;
+
+ if (!templateName) {
+ return { error: "템플릿 이름은 필수입니다." };
+ }
+
+ // 기본 업데이트 데이터
+ const updateData: Record<string, any> = {
+ templateName,
+ status,
+ validityPeriod,
+ updatedAt: new Date(),
+ };
+
+
+ // 파일이 있는 경우 처리
+ if (file) {
+ // 원본 파일 이름과 확장자 분리
+ const originalFileName = file.name;
+ const fileExtension = path.extname(originalFileName);
+ const fileNameWithoutExt = path.basename(originalFileName, fileExtension);
+
+ // 해시된 파일 이름 생성
+ const timestamp = Date.now();
+ const randomHash = crypto.createHash('md5')
+ .update(`${fileNameWithoutExt}-${timestamp}-${Math.random()}`)
+ .digest('hex')
+ .substring(0, 8);
+
+ const hashedFileName = `${timestamp}-${randomHash}${fileExtension}`;
+
+ // 저장 디렉토리 설정
+ const uploadDir = path.join(process.cwd(), "public", "basicContract", "template");
+
+ // 디렉토리가 없으면 생성
+ try {
+ await fs.mkdir(uploadDir, { recursive: true });
+ } catch (err) {
+ console.log("Directory already exists or creation failed:", err);
+ }
+
+ // 파일 경로 설정
+ const filePath = path.join(uploadDir, hashedFileName);
+ const publicFilePath = `/basicContract/template/${hashedFileName}`;
+
+ // 파일을 ArrayBuffer로 변환
+ const arrayBuffer = await file.arrayBuffer();
+ const buffer = Buffer.from(arrayBuffer);
+
+ // 파일 저장
+ await fs.writeFile(filePath, buffer);
+
+ // 기존 파일 정보 가져오기
+ const existingTemplate = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, id)
+ });
+
+ // 기존 파일이 있다면 삭제
+ if (existingTemplate?.filePath) {
+ try {
+ const existingFilePath = path.join(process.cwd(), "public", existingTemplate.filePath);
+ await fs.access(existingFilePath); // 파일 존재 확인
+ await fs.unlink(existingFilePath); // 파일 삭제
+ } catch (error) {
+ console.log("기존 파일 삭제 실패 또는 파일이 없음:", error);
+ }
+ }
+
+ // 업데이트 데이터에 파일 정보 추가
+ updateData.fileName = originalFileName;
+ updateData.filePath = publicFilePath;
+ }
+
+ // DB 업데이트
+ await db.transaction(async (tx) => {
+ await tx
+ .update(basicContractTemplates)
+ .set(updateData)
+ .where(eq(basicContractTemplates.id, id));
+ });
+
+ // 캐시 무효화 (다양한 방법 시도)
+ revalidateTag("basic-contract-templates");
+ revalidateTag("template-status-counts");
+ revalidateTag("templates");
+
+ return { success: true };
+ } catch (error) {
+ console.error("템플릿 업데이트 오류:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "템플릿 업데이트 중 오류가 발생했습니다."
+ };
+ }
+}
+
+interface RequestBasicContractInfoProps {
+ vendorIds: number[];
+ requestedBy: number;
+ templateId: number;
+}
+
+
+export async function requestBasicContractInfo({
+ vendorIds,
+ requestedBy,
+ templateId
+}: RequestBasicContractInfoProps): Promise<{ success?: boolean; error?: string }> {
+ unstable_noStore();
+
+ if (!vendorIds || vendorIds.length === 0) {
+ return { error: "요청할 협력업체가 선택되지 않았습니다." };
+ }
+
+ if (!templateId) {
+ return { error: "계약서 템플릿이 선택되지 않았습니다." };
+ }
+
+ try {
+ // 1. 선택된 템플릿 정보 가져오기
+ const template = await db.query.basicContractTemplates.findFirst({
+ where: eq(basicContractTemplates.id, templateId)
+ });
+
+ if (!template) {
+ return { error: "선택한 템플릿을 찾을 수 없습니다." };
+ }
+
+ // 2. 협력업체 정보 가져오기
+ const vendorList = await db
+ .select()
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ if (!vendorList || vendorList.length === 0) {
+ return { error: "선택한 협력업체 정보를 찾을 수 없습니다." };
+ }
+
+ // 3. 각 협력업체에 대해 기본계약 레코드 생성 및 이메일 발송
+ const results = await Promise.all(
+ vendorList.map(async (vendor) => {
+ if (!vendor.email) return; // 이메일이 없으면 스킵
+
+ try {
+ // 3-1. basic_contract 테이블에 레코드 추가
+ const [newContract] = await db
+ .insert(basicContract)
+ .values({
+ templateId: template.id,
+ vendorId: vendor.id,
+ requestedBy: requestedBy,
+ status: "PENDING",
+ fileName: template.fileName, // 템플릿 파일 이름 사용
+ filePath: template.filePath, // 템플릿 파일 경로 사용
+ })
+ .returning();
+
+ // 3-2. 협력업체에 이메일 발송
+ const subject = `[${process.env.COMPANY_NAME || '회사명'}] 기본계약서 서명 요청`;
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+ // 로그인 또는 서명 페이지 URL 생성
+ const baseUrl = `http://${host}`
+ const loginUrl = `${baseUrl}/partners/basic-contract`;
+
+ // 사용자 언어 설정 (기본값은 한국어)
+ const userLang = "ko";
+
+ // 이메일 발송
+ await sendEmail({
+ to: vendor.email,
+ subject,
+ template: "contract-sign-request", // 이메일 템플릿 이름
+ context: {
+ vendorName: vendor.vendorName,
+ contractId: newContract.id,
+ templateName: template.templateName,
+ loginUrl,
+ language: userLang,
+ },
+ });
+
+ return { vendorId: vendor.id, success: true };
+ } catch (err) {
+ console.error(`협력업체 ${vendor.id} 처리 중 오류:`, err);
+ return { vendorId: vendor.id, success: false, error: getErrorMessage(err) };
+ }
+ })
+ );
+
+ // 4. 실패한 케이스가 있는지 확인
+ const failedVendors = results.filter(r => r && !r.success);
+
+ if (failedVendors.length > 0) {
+ console.error("일부 협력업체 처리 실패:", failedVendors);
+ if (failedVendors.length === vendorIds.length) {
+ // 모든 협력업체 처리 실패
+ return { error: "모든 협력업체에 대한 처리가 실패했습니다." };
+ } else {
+ // 일부 협력업체만 처리 실패
+ return {
+ success: true,
+ error: `${results.length - failedVendors.length}개 협력업체 처리 성공, ${failedVendors.length}개 처리 실패`
+ };
+ }
+ }
+
+ // 5. 캐시 무효화
+ revalidateTag("basic-contract-requests");
+
+ return { success: true };
+ } catch (error) {
+ console.error("기본계약서 요청 중 오류 발생:", error);
+ return {
+ error: error instanceof Error
+ ? error.message
+ : "기본계약서 요청 처리 중 오류가 발생했습니다."
+ };
+ }
+}
+
+
+export async function getBasicContracts(input: GetBasciContractsSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: basicContractView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(ilike(basicContractView.templateName, s),
+ ilike(basicContractView.vendorName, s)
+ , ilike(basicContractView.vendorCode, s)
+ , ilike(basicContractView.vendorEmail, s)
+ , ilike(basicContractView.status, s)
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere // and()함수로 결합 or or() 등으로 결합
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ )
+ : [asc(basicContractView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectBasicContracts(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countBasicContracts(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["basicContractView"], // revalidateTag("basicContractView") 호출 시 무효화
+ }
+ )();
+}
+
+
+export async function getBasicContractsByVendorId(
+ input: GetBasciContractsSchema,
+ vendorId: number
+) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: basicContractView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(basicContractView.templateName, s),
+ ilike(basicContractView.vendorName, s),
+ ilike(basicContractView.vendorCode, s),
+ ilike(basicContractView.vendorEmail, s),
+ ilike(basicContractView.status, s)
+ );
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ // 벤더 ID 필터링 조건 추가
+ const vendorCondition = eq(basicContractView.vendorId, vendorId);
+
+ const finalWhere = and(
+ // 항상 벤더 ID 조건을 포함
+ vendorCondition,
+ // 기존 조건들
+ advancedWhere,
+ globalWhere
+ );
+
+ const where = finalWhere;
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(basicContractView[item.id]) : asc(basicContractView[item.id])
+ )
+ : [asc(basicContractView.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectBasicContracts(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countBasicContracts(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input), String(vendorId)], // 캐싱 키에 vendorId 추가
+ {
+ revalidate: 3600,
+ tags: ["basicContractView-vendor"], // revalidateTag("basicContractView") 호출 시 무효화
+ }
+ )();
+}
+
+export async function getAllTemplates(): Promise<BasicContractTemplate[]> {
+ try {
+ return await findAllTemplates();
+ } catch (err) {
+ throw new Error("Failed to get templates");
+ }
+}
+
+
+interface VendorTemplateStatus {
+ vendorId: number;
+ vendorName: string;
+ templateId: number;
+ templateName: string;
+ status: string;
+ createdAt: Date;
+ isExpired: boolean; // 요청이 오래되었는지 (예: 30일 이상)
+ isUpdated: boolean; // 템플릿이 업데이트되었는지
+}
+
+/**
+ * 협력업체와 템플릿 조합에 대한 계약 요청 상태를 확인합니다.
+ */
+// 계약 상태 확인 API 함수
+export async function checkContractRequestStatus(
+ vendorIds: number[],
+ templateIds: number[]
+) {
+ try {
+ // 각 협력업체-템플릿 조합에 대한 최신 계약 요청 상태 확인
+ const requests = await db
+ .select({
+ id: basicContract.id,
+ vendorId: basicContract.vendorId,
+ templateId: basicContract.templateId,
+ status: basicContract.status,
+ createdAt: basicContract.createdAt,
+ updatedAt: basicContract.updatedAt,
+ // completedAt 필드 추가 필요
+ completedAt: basicContract.completedAt, // 계약 완료 날짜
+ })
+ .from(basicContract)
+ .where(
+ and(
+ inArray(basicContract.vendorId, vendorIds),
+ inArray(basicContract.templateId, templateIds)
+ )
+ )
+ .orderBy(desc(basicContract.createdAt));
+
+ // 협력업체 정보 가져오기
+ const vendorData = await db
+ .select({
+ id: vendors.id,
+ vendorName: vendors.vendorName,
+ })
+ .from(vendors)
+ .where(inArray(vendors.id, vendorIds));
+
+ // 템플릿 정보 가져오기
+ const templateData = await db
+ .select({
+ id: basicContractTemplates.id,
+ templateName: basicContractTemplates.templateName,
+ updatedAt: basicContractTemplates.updatedAt,
+ validityPeriod: basicContractTemplates.validityPeriod, // 템플릿별 유효기간(개월)
+ })
+ .from(basicContractTemplates)
+ .where(inArray(basicContractTemplates.id, templateIds));
+
+ // 데이터 가공 - 협력업체별, 템플릿별로 상태 매핑
+ const vendorMap = new Map(vendorData.map(v => [v.id, v]));
+ const templateMap = new Map(templateData.map(t => [t.id, t]));
+
+ const uniqueRequests = new Map();
+
+ // 각 협력업체-템플릿 조합에 대해 가장 최근 요청만 사용
+ requests.forEach(req => {
+ const key = `${req.vendorId}-${req.templateId}`;
+ if (!uniqueRequests.has(key)) {
+ uniqueRequests.set(key, req);
+ }
+ });
+
+ // 인터페이스를 임포트하거나 이 함수 내에서/위에서 재정의
+ interface VendorTemplateStatus {
+ vendorId: number;
+ vendorName: string;
+ templateId: number;
+ templateName: string;
+ status: string;
+ createdAt: Date;
+ completedAt?: Date;
+ isExpired: boolean;
+ isUpdated: boolean;
+ isContractExpired: boolean;
+ }
+
+ // 명시적 타입 지정
+ const statusData: VendorTemplateStatus[] = [];
+
+ // 요청 만료 기준 - 30일
+ const REQUEST_EXPIRATION_DAYS = 30;
+
+ // 기본 계약 유효기간 - 12개월 (템플릿별로 다르게 설정 가능)
+ const DEFAULT_CONTRACT_VALIDITY_MONTHS = 12;
+
+ const now = new Date();
+
+ // 모든 협력업체-템플릿 조합에 대해 상태 확인
+ vendorIds.forEach(vendorId => {
+ templateIds.forEach(templateId => {
+ const key = `${vendorId}-${templateId}`;
+ const request = uniqueRequests.get(key);
+ const vendor = vendorMap.get(vendorId);
+ const template = templateMap.get(templateId);
+
+ if (!vendor || !template) return;
+
+ let status = "NONE"; // 기본 상태: 요청 없음
+ let createdAt = new Date();
+ let completedAt = null;
+ let isExpired = false;
+ let isUpdated = false;
+ let isContractExpired = false;
+
+ if (request) {
+ status = request.status;
+ createdAt = request.createdAt;
+ completedAt = request.completedAt;
+
+ // 요청이 오래되었는지 확인 (PENDING 상태일 때만 적용)
+ if (status === "PENDING") {
+ isExpired = differenceInDays(now, createdAt) > REQUEST_EXPIRATION_DAYS;
+ }
+
+ // 요청 이후 템플릿이 업데이트되었는지 확인
+ if (template.updatedAt && request.createdAt) {
+ isUpdated = template.updatedAt > request.createdAt;
+ }
+
+ // 계약 유효기간 만료 확인 (COMPLETED 상태이고 completedAt이 있는 경우)
+ if (status === "COMPLETED" && completedAt) {
+ // 템플릿별 유효기간 또는 기본값 사용
+ const validityMonths = template.validityPeriod || DEFAULT_CONTRACT_VALIDITY_MONTHS;
+
+ // 계약 만료일 계산 (완료일 + 유효기간)
+ const expiryDate = addYears(completedAt, validityMonths / 12);
+
+ // 현재 날짜가 만료일 이후인지 확인
+ isContractExpired = isBefore(expiryDate, now);
+ }
+ }
+
+ statusData.push({
+ vendorId,
+ vendorName: vendor.vendorName,
+ templateId,
+ templateName: template.templateName,
+ status,
+ createdAt,
+ completedAt,
+ isExpired,
+ isUpdated,
+ isContractExpired,
+ });
+ });
+ });
+
+ return { data: statusData };
+ } catch (error) {
+ console.error("계약 상태 확인 중 오류:", error);
+ return {
+ data: [],
+ error: error instanceof Error ? error.message : "계약 상태 확인 중 오류가 발생했습니다."
+ };
+ }
+}
\ No newline at end of file diff --git a/lib/basic-contract/status/basic-contract-columns.tsx b/lib/basic-contract/status/basic-contract-columns.tsx new file mode 100644 index 00000000..6ca4a096 --- /dev/null +++ b/lib/basic-contract/status/basic-contract-columns.tsx @@ -0,0 +1,213 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Paperclip } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { basicContractColumnsConfig } from "@/config/basicContractColumnsConfig" +import { BasicContractView } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>> +} + +/** + * 파일 다운로드 함수 + */ +/** + * 파일 다운로드 함수 + */ +const handleFileDownload = (filePath: string | null, fileName: string | null) => { + if (!filePath || !fileName) { + toast.error("파일 정보가 없습니다."); + return; + } + + try { + // 전체 URL 생성 + const fullUrl = `${window.location.origin}${filePath}`; + + // a 태그를 생성하여 다운로드 실행 + const link = document.createElement('a'); + link.href = fullUrl; + link.download = fileName; // 다운로드될 파일명 설정 + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success("파일 다운로드를 시작합니다."); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } +}; + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<BasicContractView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 파일 다운로드 컬럼 (아이콘) + // ---------------------------------------------------------------- + const downloadColumn: ColumnDef<BasicContractView> = { + id: "download", + header: "", + cell: ({ row }) => { + const template = row.original; + + return ( + <Button + variant="ghost" + size="icon" + onClick={() => handleFileDownload(template.filePath, template.fileName)} + title={`${template.fileName} 다운로드`} + className="hover:bg-muted" + > + <Paperclip className="h-4 w-4" /> + <span className="sr-only">다운로드</span> + </Button> + ); + }, + maxSize: 30, + enableSorting: false, + } + + + // ---------------------------------------------------------------- + // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractView>[] } + const groupMap: Record<string, ColumnDef<BasicContractView>[]> = {} + + basicContractColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<BasicContractView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // 날짜 형식 처리 + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDateTime(dateVal) + } + + // Status 컬럼에 Badge 적용 + if (cfg.id === "status") { + const status = row.getValue(cfg.id) as string + const isActive = status === "ACTIVE" + + return ( + <Badge + variant={isActive ? "default" : "secondary"} + > + {isActive ? "활성" : "비활성"} + </Badge> + ) + } + + // 나머지 컬럼은 그대로 값 표시 + return row.getValue(cfg.id) ?? "" + }, + minSize: 80, + + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<BasicContractView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 5) 최종 컬럼 배열: select, download, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + downloadColumn, // 다운로드 컬럼 추가 + ...nestedColumns, + ] +}
\ No newline at end of file diff --git a/lib/basic-contract/status/basic-contract-table.tsx b/lib/basic-contract/status/basic-contract-table.tsx new file mode 100644 index 00000000..22845144 --- /dev/null +++ b/lib/basic-contract/status/basic-contract-table.tsx @@ -0,0 +1,95 @@ +"use client"; + +import * as React from "react"; +import { DataTable } from "@/components/data-table/data-table"; +import { Button } from "@/components/ui/button"; +import { Plus, Loader2 } from "lucide-react"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { toast } from "sonner"; +import { getColumns } from "./basic-contract-columns"; +import { getBasicContracts } from "../service"; +import { BasicContractView } from "@/db/schema"; +import { BasicContractTableToolbarActions } from "./basicContract-table-toolbar-actions"; + + +interface BasicTemplateTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getBasicContracts>>, + ] + > +} + + +export function BasicContractsTable({ promises }: BasicTemplateTableProps) { + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<BasicContractView> | null>(null) + + + const [{ data, pageCount }] = + React.use(promises) + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // config 기반으로 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [ + { id: "templateName", label: "템플릿명", type: "text" }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "활성", value: "ACTIVE" }, + { label: "비활성", value: "INACTIVE" }, + ] + }, + { id: "userName", label: "요청자", type: "text" }, + { id: "vendorName", label: "업체명", type: "text" }, + { id: "vendorCode", label: "업체코드", type: "text" }, + { id: "vendorEmail", label: "업체대표이메일", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + // filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <BasicContractTableToolbarActions table={table} /> + + </DataTableAdvancedToolbar> + </DataTable> + + </> + + ); +}
\ No newline at end of file diff --git a/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx new file mode 100644 index 00000000..cee94790 --- /dev/null +++ b/lib/basic-contract/status/basicContract-table-toolbar-actions.tsx @@ -0,0 +1,40 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { BasicContractView } from "@/db/schema" + +interface TemplateTableToolbarActionsProps { + table: Table<BasicContractView> +} + +export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + + return ( + <div className="flex items-center gap-2"> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "basci-contract", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx new file mode 100644 index 00000000..cf0986f0 --- /dev/null +++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx @@ -0,0 +1,359 @@ +"use client";
+
+import * as React from "react";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import * as z from "zod";
+import { toast } from "sonner";
+import { v4 as uuidv4 } from 'uuid';
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { Button } from "@/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+ DropzoneInput
+} from "@/components/ui/dropzone";
+import { Progress } from "@/components/ui/progress";
+import { useRouter } from "next/navigation"
+
+// 유효기간 필드가 추가된 계약서 템플릿 스키마 정의
+const templateFormSchema = z.object({
+ templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
+ validityPeriod: z.coerce
+ .number({ invalid_type_error: "유효기간은 숫자여야 합니다." })
+ .int("유효기간은 정수여야 합니다.")
+ .min(1, "유효기간은 최소 1개월 이상이어야 합니다.")
+ .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.")
+ .default(12),
+ file: z
+ .instanceof(File, { message: "파일을 업로드해주세요." })
+ .refine((file) => file.size <= 100 * 1024 * 1024, {
+ message: "파일 크기는 100MB 이하여야 합니다.",
+ })
+ .refine(
+ (file) => file.type === 'application/pdf',
+ { message: "PDF 파일만 업로드 가능합니다." }
+ ),
+ status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
+});
+
+type TemplateFormValues = z.infer<typeof templateFormSchema>;
+
+export function AddTemplateDialog() {
+ const [open, setOpen] = React.useState(false);
+ const [isLoading, setIsLoading] = React.useState(false);
+ const [selectedFile, setSelectedFile] = React.useState<File | null>(null);
+ const [uploadProgress, setUploadProgress] = React.useState(0);
+ const [showProgress, setShowProgress] = React.useState(false);
+ const router = useRouter()
+
+ // 기본값 설정
+ const defaultValues: Partial<TemplateFormValues> = {
+ templateName: "",
+ validityPeriod: 12, // 기본값 1년
+ status: "ACTIVE",
+ };
+
+ // 폼 초기화
+ const form = useForm<TemplateFormValues>({
+ resolver: zodResolver(templateFormSchema),
+ defaultValues,
+ mode: "onChange",
+ });
+
+ // 폼 값 감시
+ const templateName = form.watch("templateName");
+ const validityPeriod = form.watch("validityPeriod");
+ const file = form.watch("file");
+
+ // 파일 선택 핸들러
+ const handleFileChange = (files: File[]) => {
+ if (files.length > 0) {
+ const file = files[0];
+ setSelectedFile(file);
+ form.setValue("file", file);
+ }
+ };
+
+ // 청크 크기 설정 (1MB)
+ const CHUNK_SIZE = 1 * 1024 * 1024;
+
+ // 파일을 청크로 분할하여 업로드하는 함수
+ const uploadFileInChunks = async (file: File, fileId: string) => {
+ const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
+ setShowProgress(true);
+ setUploadProgress(0);
+
+ for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
+ const start = chunkIndex * CHUNK_SIZE;
+ const end = Math.min(start + CHUNK_SIZE, file.size);
+ const chunk = file.slice(start, end);
+
+ const formData = new FormData();
+ formData.append('chunk', chunk);
+ formData.append('filename', file.name);
+ formData.append('chunkIndex', chunkIndex.toString());
+ formData.append('totalChunks', totalChunks.toString());
+ formData.append('fileId', fileId);
+
+ try {
+ const response = await fetch('/api/upload/basicContract/chunk', {
+ method: 'POST',
+ body: formData,
+ });
+
+ if (!response.ok) {
+ throw new Error(`청크 업로드 실패: ${response.statusText}`);
+ }
+
+ // 진행률 업데이트
+ const progress = Math.round(((chunkIndex + 1) / totalChunks) * 100);
+ setUploadProgress(progress);
+
+ const result = await response.json();
+
+ // 마지막 청크인 경우 파일 경로 반환
+ if (chunkIndex === totalChunks - 1) {
+ return result;
+ }
+ } catch (error) {
+ console.error(`청크 ${chunkIndex} 업로드 오류:`, error);
+ throw error;
+ }
+ }
+ };
+
+ // 폼 제출 핸들러
+ async function onSubmit(formData: TemplateFormValues) {
+ setIsLoading(true);
+ try {
+ if (!formData.file) {
+ throw new Error("파일이 선택되지 않았습니다.");
+ }
+
+ // 고유 파일 ID 생성
+ const fileId = uuidv4();
+
+ // 파일 청크 업로드
+ const uploadResult = await uploadFileInChunks(formData.file, fileId);
+
+ if (!uploadResult.success) {
+ throw new Error("파일 업로드에 실패했습니다.");
+ }
+
+ // 메타데이터 저장
+ const saveResponse = await fetch('/api/upload/basicContract/complete', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ templateName: formData.templateName,
+ validityPeriod: formData.validityPeriod, // 유효기간 추가
+ status: formData.status,
+ fileName: uploadResult.fileName,
+ filePath: uploadResult.filePath,
+ }),
+ next: { tags: ["basic-contract-templates"] },
+ });
+
+ const saveResult = await saveResponse.json();
+
+ if (!saveResult.success) {
+ throw new Error("템플릿 정보 저장에 실패했습니다.");
+ }
+
+ toast.success('템플릿이 성공적으로 추가되었습니다.');
+ form.reset();
+ setSelectedFile(null);
+ setOpen(false);
+ setShowProgress(false);
+
+ router.refresh();
+ } catch (error) {
+ console.error("Submit error:", error);
+ toast.error("템플릿 추가 중 오류가 발생했습니다.");
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ // 모달이 닫힐 때 폼 초기화
+ React.useEffect(() => {
+ if (!open) {
+ form.reset();
+ setSelectedFile(null);
+ setShowProgress(false);
+ setUploadProgress(0);
+ }
+ }, [open, form]);
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset();
+ }
+ setOpen(nextOpen);
+ }
+
+ // 유효기간 선택 옵션
+ const validityOptions = [
+ { value: "3", label: "3개월" },
+ { value: "6", label: "6개월" },
+ { value: "12", label: "1년" },
+ { value: "24", label: "2년" },
+ { value: "36", label: "3년" },
+ { value: "60", label: "5년" },
+ ];
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ 템플릿 추가
+ </Button>
+ </DialogTrigger>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>새 기본계약서 템플릿 추가</DialogTitle>
+ <DialogDescription>
+ 템플릿 이름을 입력하고 계약서 파일을 업로드하세요.
+ <span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="templateName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 이름 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="템플릿 이름을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="validityPeriod"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 계약 유효기간 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ value={field.value?.toString()}
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="유효기간을 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {validityOptions.map(option => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormDescription>
+ 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>
+ 계약서 파일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ accept={{
+ 'application/pdf': ['.pdf']
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile ? selectedFile.name : "PDF 파일을 여기에 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
+ : "또는 클릭하여 PDF 파일을 선택하세요 (최대 100MB)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {showProgress && (
+ <div className="space-y-2">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행률</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isLoading || !templateName || !validityPeriod || !file}
+ >
+ {isLoading ? "처리 중..." : "추가"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ );
+}
\ No newline at end of file diff --git a/lib/basic-contract/template/basic-contract-template-columns.tsx b/lib/basic-contract/template/basic-contract-template-columns.tsx new file mode 100644 index 00000000..b0486fe4 --- /dev/null +++ b/lib/basic-contract/template/basic-contract-template-columns.tsx @@ -0,0 +1,245 @@ +"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Download, Ellipsis, Paperclip } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } from "@/lib/utils"
+import { Badge } from "@/components/ui/badge"
+import { Button } from "@/components/ui/button"
+import { Checkbox } from "@/components/ui/checkbox"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { basicContractTemplateColumnsConfig } from "@/config/basicContractColumnsConfig"
+import { BasicContractTemplate } from "@/db/schema"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractTemplate> | null>>
+}
+
+/**
+ * 파일 다운로드 함수
+ */
+const handleFileDownload = (filePath: string, fileName: string) => {
+ try {
+ // 전체 URL 생성
+ const fullUrl = `${window.location.origin}${filePath}`;
+
+ // a 태그를 생성하여 다운로드 실행
+ const link = document.createElement('a');
+ link.href = fullUrl;
+ link.download = fileName; // 다운로드될 파일명 설정
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ toast.success("파일 다운로드를 시작합니다.");
+ } catch (error) {
+ console.error("파일 다운로드 오류:", error);
+ toast.error("파일 다운로드 중 오류가 발생했습니다.");
+ }
+};
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractTemplate>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<BasicContractTemplate> = {
+ id: "select",
+ header: ({ table }) => (
+ <Checkbox
+ checked={
+ table.getIsAllPageRowsSelected() ||
+ (table.getIsSomePageRowsSelected() && "indeterminate")
+ }
+ onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
+ aria-label="Select all"
+ className="translate-y-0.5"
+ />
+ ),
+ cell: ({ row }) => (
+ <Checkbox
+ checked={row.getIsSelected()}
+ onCheckedChange={(value) => row.toggleSelected(!!value)}
+ aria-label="Select row"
+ className="translate-y-0.5"
+ />
+ ),
+ maxSize: 30,
+ enableSorting: false,
+ enableHiding: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 2) 파일 다운로드 컬럼 (아이콘)
+ // ----------------------------------------------------------------
+ const downloadColumn: ColumnDef<BasicContractTemplate> = {
+ id: "download",
+ header: "",
+ cell: ({ row }) => {
+ const template = row.original;
+
+ return (
+ <Button
+ variant="ghost"
+ size="icon"
+ onClick={() => handleFileDownload(template.filePath, template.fileName)}
+ title={`${template.fileName} 다운로드`}
+ className="hover:bg-muted"
+ >
+ <Paperclip className="h-4 w-4" />
+ <span className="sr-only">다운로드</span>
+ </Button>
+ );
+ },
+ maxSize: 30,
+ enableSorting: false,
+ }
+
+ // ----------------------------------------------------------------
+ // 3) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ const actionsColumn: ColumnDef<BasicContractTemplate> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+
+ <DropdownMenuSeparator />
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ Delete
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ maxSize: 30,
+ }
+
+ // ----------------------------------------------------------------
+ // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractTemplate>[] }
+ const groupMap: Record<string, ColumnDef<BasicContractTemplate>[]> = {}
+
+ basicContractTemplateColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<BasicContractTemplate> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+ // 날짜 형식 처리
+ if (cfg.id === "createdAt" || cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDateTime(dateVal)
+ }
+
+ // Status 컬럼에 Badge 적용
+ if (cfg.id === "status") {
+ const status = row.getValue(cfg.id) as string
+ const isActive = status === "ACTIVE"
+
+ return (
+ <Badge
+ variant={isActive ? "default" : "secondary"}
+ >
+ {isActive ? "활성" : "비활성"}
+ </Badge>
+ )
+ }
+
+ // 나머지 컬럼은 그대로 값 표시
+ return row.getValue(cfg.id) ?? ""
+ },
+ minSize:80
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<BasicContractTemplate>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 5) 최종 컬럼 배열: select, download, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ downloadColumn, // 다운로드 컬럼 추가
+ ...nestedColumns,
+ actionsColumn,
+ ]
+}
\ No newline at end of file diff --git a/lib/basic-contract/template/basic-contract-template.tsx b/lib/basic-contract/template/basic-contract-template.tsx new file mode 100644 index 00000000..0cca3a41 --- /dev/null +++ b/lib/basic-contract/template/basic-contract-template.tsx @@ -0,0 +1,104 @@ +"use client"; + +import * as React from "react"; +import { DataTable } from "@/components/data-table/data-table"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import type { + DataTableAdvancedFilterField, + DataTableRowAction, +} from "@/types/table" +import { getBasicContractTemplates} from "../service"; +import { getColumns } from "./basic-contract-template-columns"; +import { DeleteTemplatesDialog } from "./delete-basicContract-dialog"; +import { UpdateTemplateSheet } from "./update-basicContract-sheet"; +import { TemplateTableToolbarActions } from "./basicContract-table-toolbar-actions"; +import { BasicContractTemplate } from "@/db/schema"; + + +interface BasicTemplateTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getBasicContractTemplates>>, + ] + > +} + + +export function BasicContractTemplateTable({ promises }: BasicTemplateTableProps) { + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<BasicContractTemplate> | null>(null) + + + const [{ data, pageCount }] = + React.use(promises) + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // config 기반으로 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<BasicContractTemplate>[] = [ + { id: "templateName", label: "템플릿명", type: "text" }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "활성", value: "ACTIVE" }, + { label: "비활성", value: "INACTIVE" }, + ] + }, + { id: "fileName", label: "파일명", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + // filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <TemplateTableToolbarActions table={table} /> + + </DataTableAdvancedToolbar> + </DataTable> + + <DeleteTemplatesDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + templates={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + <UpdateTemplateSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + template={rowAction?.row.original ?? null} + /> + + </> + + ); +}
\ No newline at end of file diff --git a/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx new file mode 100644 index 00000000..439fea26 --- /dev/null +++ b/lib/basic-contract/template/basicContract-table-toolbar-actions.tsx @@ -0,0 +1,53 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { DeleteTemplatesDialog } from "./delete-basicContract-dialog" +import { AddTemplateDialog } from "./add-basic-contract-template-dialog" +import { BasicContractTemplate } from "@/db/schema" + +interface TemplateTableToolbarActionsProps { + table: Table<BasicContractTemplate> +} + +export function TemplateTableToolbarActions({ table }: TemplateTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + + return ( + <div className="flex items-center gap-2"> + {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteTemplatesDialog + templates={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + <AddTemplateDialog/> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "basic-contract-template-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/template/delete-basicContract-dialog.tsx b/lib/basic-contract/template/delete-basicContract-dialog.tsx new file mode 100644 index 00000000..307bd9aa --- /dev/null +++ b/lib/basic-contract/template/delete-basicContract-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +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 { removeTemplates } from "../service" +import { BasicContractTemplate } from "@/db/schema" + +interface DeleteBasicContractsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + templates: Row<BasicContractTemplate>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteTemplatesDialog({ + templates, + showTrigger = true, + onSuccess, + ...props +}: DeleteBasicContractsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeTemplates({ + ids: templates.map((template) => template.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Templates deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({templates.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{templates.length}</span> + {templates.length === 1 ? " template" : " templates"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({templates.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{templates.length}</span> + {templates.length === 1 ? " template" : " templates"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/basic-contract/template/update-basicContract-sheet.tsx b/lib/basic-contract/template/update-basicContract-sheet.tsx new file mode 100644 index 00000000..2c6efc9b --- /dev/null +++ b/lib/basic-contract/template/update-basicContract-sheet.tsx @@ -0,0 +1,300 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import * as z from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Input } from "@/components/ui/input" +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" +import { updateTemplate } from "../service" +import { BasicContractTemplate } from "@/db/schema" + +// 업데이트 템플릿 스키마 정의 (유효기간 필드 추가) +export const updateTemplateSchema = z.object({ + templateName: z.string().min(1, "템플릿 이름은 필수입니다."), + validityPeriod: z.coerce + .number({ invalid_type_error: "유효기간은 숫자여야 합니다." }) + .int("유효기간은 정수여야 합니다.") + .min(1, "유효기간은 최소 1개월 이상이어야 합니다.") + .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.") + .default(12), + status: z.enum(["ACTIVE", "INACTIVE"], { + required_error: "상태는 필수 선택사항입니다.", + }), + file: z.instanceof(File, { message: "파일을 업로드해주세요." }).optional(), +}) + +export type UpdateTemplateSchema = z.infer<typeof updateTemplateSchema> + +interface UpdateTemplateSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + template: BasicContractTemplate | null + onSuccess?: () => void +} + +export function UpdateTemplateSheet({ template, onSuccess, ...props }: UpdateTemplateSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [selectedFile, setSelectedFile] = React.useState<File | null>(null) + + // 템플릿 데이터 확인을 위한 로그 + console.log(template) + + const form = useForm<UpdateTemplateSchema>({ + resolver: zodResolver(updateTemplateSchema), + defaultValues: { + templateName: template?.templateName ?? "", + validityPeriod: template?.validityPeriod ?? 12, // 기본값 12개월 + status: (template?.status as "ACTIVE" | "INACTIVE") || "ACTIVE" + }, + mode: "onChange" + }) + + // 파일 선택 핸들러 + const handleFileChange = (files: File[]) => { + if (files.length > 0) { + const file = files[0]; + setSelectedFile(file); + form.setValue("file", file); + } + }; + + // 템플릿 변경 시 폼 값 업데이트 + React.useEffect(() => { + if (template) { + form.reset({ + templateName: template.templateName, + validityPeriod: template.validityPeriod ?? 12, // 기존 값이 없으면 기본값 12개월 + status: template.status as "ACTIVE" | "INACTIVE", + }); + } + }, [template, form]); + + // 유효기간 선택 옵션 + const validityOptions = [ + { value: "3", label: "3개월" }, + { value: "6", label: "6개월" }, + { value: "12", label: "1년" }, + { value: "24", label: "2년" }, + { value: "36", label: "3년" }, + { value: "60", label: "5년" }, + ]; + + function onSubmit(input: UpdateTemplateSchema) { + startUpdateTransition(async () => { + if (!template) return + + // FormData 객체 생성하여 파일과 데이터를 함께 전송 + const formData = new FormData(); + formData.append("templateName", input.templateName); + formData.append("validityPeriod", input.validityPeriod.toString()); // 유효기간 추가 + formData.append("status", input.status); + + if (input.file) { + formData.append("file", input.file); + } + + try { + // 서비스 함수 호출 + const { error } = await updateTemplate({ + id: template.id, + formData, + }); + + if (error) { + toast.error(error); + return; + } + + form.reset(); + setSelectedFile(null); + props.onOpenChange?.(false); + toast.success("템플릿이 성공적으로 업데이트되었습니다."); + onSuccess?.(); + } catch (error) { + console.error("Update error:", error); + toast.error("템플릿 업데이트 중 오류가 발생했습니다."); + } + }); + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>템플릿 업데이트</SheetTitle> + <SheetDescription> + 템플릿 정보를 수정하고 변경사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + <FormField + control={form.control} + name="templateName" + render={({ field }) => ( + <FormItem> + <FormLabel>템플릿 이름</FormLabel> + <FormControl> + <Input placeholder="템플릿 이름을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="validityPeriod" + render={({ field }) => ( + <FormItem> + <FormLabel>계약 유효기간</FormLabel> + <Select + value={field.value?.toString()} + onValueChange={(value) => field.onChange(parseInt(value))} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="유효기간을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {validityOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormDescription> + 계약서의 유효 기간을 설정합니다. 이 기간이 지나면 재계약이 필요합니다. + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select + defaultValue={field.value} + onValueChange={field.onChange} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="템플릿 상태 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + <SelectItem value="ACTIVE">활성</SelectItem> + <SelectItem value="INACTIVE">비활성</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="file" + render={() => ( + <FormItem> + <FormLabel>템플릿 파일 (선택사항)</FormLabel> + <FormControl> + <Dropzone + onDrop={handleFileChange} + > + <DropzoneZone> + <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" /> + <DropzoneTitle> + {selectedFile + ? selectedFile.name + : template?.fileName + ? `현재 파일: ${template.fileName}` + : "새 파일을 드래그하세요"} + </DropzoneTitle> + <DropzoneDescription> + {selectedFile + ? `파일 크기: ${(selectedFile.size / 1024).toFixed(2)} KB` + : "또는 클릭하여 파일을 선택하세요 (선택사항)"} + </DropzoneDescription> + <DropzoneInput /> + </DropzoneZone> + </Dropzone> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + disabled={isUpdatePending || !form.formState.isValid} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/validations.ts b/lib/basic-contract/validations.ts new file mode 100644 index 00000000..5a5bf5b8 --- /dev/null +++ b/lib/basic-contract/validations.ts @@ -0,0 +1,87 @@ +import * as z from "zod";
+import { createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,parseAsBoolean
+} from "nuqs/server"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { BasicContractTemplate, BasicContractView } from "@/db/schema";
+
+export const basicContractTemplateSchema = z.object({
+ templateName: z.string().min(1, "템플릿 이름은 필수입니다."),
+
+ // 유효기간을 숫자로 변경하고 적절한 검증 추가
+ validityPeriod: z.coerce
+ .number({
+ required_error: "유효기간은 필수입니다.",
+ invalid_type_error: "유효기간은 숫자여야 합니다."
+ })
+ .int("유효기간은 정수여야 합니다.")
+ .min(1, "유효기간은 최소 1개월 이상이어야 합니다.")
+ .max(120, "유효기간은 최대 120개월(10년)을 초과할 수 없습니다.")
+ .default(12) // 기본값 1년(12개월)
+ .describe("계약 유효기간(개월)"),
+
+ status: z.enum(["ACTIVE", "INACTIVE"], {
+ required_error: "상태는 필수 선택사항입니다.",
+ invalid_type_error: "올바른 상태 값이 아닙니다."
+ }).default("ACTIVE"),
+
+ fileName: z.string().min(1, "파일 이름은 필수입니다."),
+ filePath: z.string().min(1, "파일 경로는 필수입니다."),
+});
+
+export const searchParamsTemplatesCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<BasicContractTemplate>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+export const createBasicContractTemplateSchema = basicContractTemplateSchema.extend({});
+
+export const updateBasicContractTemplateSchema = basicContractTemplateSchema.partial().extend({
+ id: z.number(),
+});
+
+export const deleteBasicContractTemplateSchema = z.object({
+ id: z.number(),
+});
+
+export type GetBasicContractTemplatesSchema = Awaited<ReturnType<typeof searchParamsTemplatesCache.parse>>
+
+
+export type CreateBasicContractTemplateSchema = z.infer<typeof createBasicContractTemplateSchema>;
+export type UpdateBasicContractTemplateSchema = z.infer<typeof updateBasicContractTemplateSchema>;
+export type DeleteBasicContractTemplateSchema = z.infer<typeof deleteBasicContractTemplateSchema>;
+
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<BasicContractView>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+});
+
+export type GetBasciContractsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
diff --git a/lib/basic-contract/vendor-table/basic-contract-columns.tsx b/lib/basic-contract/vendor-table/basic-contract-columns.tsx new file mode 100644 index 00000000..b79487d7 --- /dev/null +++ b/lib/basic-contract/vendor-table/basic-contract-columns.tsx @@ -0,0 +1,214 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Paperclip } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { basicContractColumnsConfig, basicContractVendorColumnsConfig } from "@/config/basicContractColumnsConfig" +import { BasicContractView } from "@/db/schema" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BasicContractView> | null>> +} + +/** + * 파일 다운로드 함수 + */ +/** + * 파일 다운로드 함수 + */ +const handleFileDownload = (filePath: string | null, fileName: string | null) => { + if (!filePath || !fileName) { + toast.error("파일 정보가 없습니다."); + return; + } + + try { + // 전체 URL 생성 + const fullUrl = `${window.location.origin}${filePath}`; + + // a 태그를 생성하여 다운로드 실행 + const link = document.createElement('a'); + link.href = fullUrl; + link.download = fileName; // 다운로드될 파일명 설정 + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + toast.success("파일 다운로드를 시작합니다."); + } catch (error) { + console.error("파일 다운로드 오류:", error); + toast.error("파일 다운로드 중 오류가 발생했습니다."); + } +}; + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BasicContractView>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<BasicContractView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + maxSize: 30, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 파일 다운로드 컬럼 (아이콘) + // ---------------------------------------------------------------- + const downloadColumn: ColumnDef<BasicContractView> = { + id: "download", + header: "", + cell: ({ row }) => { + const template = row.original; + const filePath = template.status === "PENDING" ? template.filePath : template.signedFilePath + + return ( + <Button + variant="ghost" + size="icon" + onClick={() => handleFileDownload(filePath, template.fileName)} + title={`${template.fileName} 다운로드`} + className="hover:bg-muted" + > + <Paperclip className="h-4 w-4" /> + <span className="sr-only">다운로드</span> + </Button> + ); + }, + maxSize: 30, + enableSorting: false, + } + + + // ---------------------------------------------------------------- + // 4) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 4-1) groupMap: { [groupName]: ColumnDef<BasicContractView>[] } + const groupMap: Record<string, ColumnDef<BasicContractView>[]> = {} + + basicContractVendorColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<BasicContractView> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + // 날짜 형식 처리 + if (cfg.id === "createdAt" || cfg.id === "updatedAt" || cfg.id === "completedAt") { + const dateVal = cell.getValue() as Date + return formatDateTime(dateVal) + } + + // Status 컬럼에 Badge 적용 + if (cfg.id === "status") { + const status = row.getValue(cfg.id) as string + const isPending = status === "PENDING" + + return ( + <Badge + variant={!isPending ? "default" : "secondary"} + > + {status} + </Badge> + ) + } + + // 나머지 컬럼은 그대로 값 표시 + return row.getValue(cfg.id) ?? "" + }, + minSize: 80, + + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 4-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<BasicContractView>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 5) 최종 컬럼 배열: select, download, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + downloadColumn, // 다운로드 컬럼 추가 + ...nestedColumns, + ] +}
\ 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 new file mode 100644 index 00000000..28a4fd71 --- /dev/null +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -0,0 +1,318 @@ +"use client"; + +import * as React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { formatDate } from "@/lib/utils"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { BasicContractSignViewer } from "@/lib/basic-contract/viewer/basic-contract-sign-viewer"; +import type { WebViewerInstance } from "@pdftron/webviewer"; +import type { BasicContractView } from "@/db/schema"; +import { + Upload, + FileSignature, + CheckCircle2, + Search, + Clock, + FileText, + User, + AlertCircle, + Calendar +} from "lucide-react"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Separator } from "@/components/ui/separator"; +import { useRouter } from "next/navigation" + +// 수정된 props 인터페이스 +interface BasicContractSignDialogProps { + contracts: BasicContractView[]; + onSuccess?: () => void; +} + +export function BasicContractSignDialog({ contracts, onSuccess }: BasicContractSignDialogProps) { + const [open, setOpen] = React.useState(false); + const [selectedContract, setSelectedContract] = React.useState<BasicContractView | null>(null); + const [instance, setInstance] = React.useState<null | WebViewerInstance>(null); + const [searchTerm, setSearchTerm] = React.useState(""); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const router = useRouter() + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (isOpen: boolean) => { + setOpen(isOpen); + + // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택 + if (isOpen && contracts.length > 0 && !selectedContract) { + setSelectedContract(contracts[0]); + } + + if (!isOpen) { + setSelectedContract(null); + setSearchTerm(""); + } + }; + + // 계약서 선택 핸들러 + const handleSelectContract = (contract: BasicContractView) => { + setSelectedContract(contract); + }; + + // 검색된 계약서 필터링 + const filteredContracts = React.useMemo(() => { + if (!searchTerm.trim()) return contracts; + + const term = searchTerm.toLowerCase(); + return contracts.filter(contract => + (contract.templateName || '').toLowerCase().includes(term) || + (contract.userName || '').toLowerCase().includes(term) + ); + }, [contracts, searchTerm]); + + // 다이얼로그가 열릴 때 첫 번째 계약서 자동 선택 + React.useEffect(() => { + if (open && contracts.length > 0 && !selectedContract) { + setSelectedContract(contracts[0]); + } + }, [open, contracts, selectedContract]); + + // 서명 완료 핸들러 + const completeSign = async () => { + if (!instance || !selectedContract) return; + + setIsSubmitting(true); + try { + const { documentViewer, annotationManager } = instance.Core; + const doc = documentViewer.getDocument(); + const xfdfString = await annotationManager.exportAnnotations(); + + const data = await doc.getFileData({ + xfdfString, + downloadType: "pdf", + }); + + // FormData 생성 및 파일 추가 + const formData = new FormData(); + formData.append('file', new Blob([data], { type: 'application/pdf' })); + formData.append('tableRowId', selectedContract.id.toString()); + formData.append('templateName', selectedContract.fileName || ''); + + // API 호출 + const response = await fetch('/api/upload/signed-contract', { + method: 'POST', + body: formData, + next: { tags: ["basicContractView-vendor"] }, + }); + + const result = await response.json(); + + if (result.result) { + toast.success("서명이 성공적으로 완료되었습니다.", { + description: "문서가 성공적으로 처리되었습니다.", + icon: <CheckCircle2 className="h-5 w-5 text-green-500" /> + }); + router.refresh(); + setOpen(false); + if (onSuccess) { + onSuccess(); + } + } else { + toast.error("서명 처리 중 오류가 발생했습니다.", { + description: result.error, + icon: <AlertCircle className="h-5 w-5 text-red-500" /> + }); + } + } catch (error) { + console.error("서명 완료 중 오류:", error); + toast.error("서명 처리 중 오류가 발생했습니다."); + } finally { + setIsSubmitting(false); + } + }; + + // 서명 대기중(PENDING) 계약서가 있는지 확인 + const hasPendingContracts = contracts.length > 0; + + return ( + <> + {/* 서명 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setOpen(true)} + disabled={!hasPendingContracts} + className="gap-2 transition-all hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200" + > + <Upload className="size-4 text-blue-500" aria-hidden="true" /> + <span className="hidden sm:inline flex items-center"> + 서명하기 + {contracts.length > 0 && ( + <Badge variant="secondary" className="ml-2 bg-blue-100 text-blue-700 hover:bg-blue-200"> + {contracts.length} + </Badge> + )} + </span> + </Button> + + {/* 서명 다이얼로그 - 고정 높이 유지 */} + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="max-w-5xl h-[650px] w-[90vw] p-0 overflow-hidden rounded-lg shadow-lg border border-gray-200"> + <DialogHeader className="p-6 bg-gradient-to-r from-blue-50 to-purple-50 border-b"> + <DialogTitle className="text-xl font-bold flex items-center text-gray-800"> + <FileSignature className="mr-2 h-5 w-5 text-blue-500" /> + 기본계약서 및 관련문서 서명 + </DialogTitle> + </DialogHeader> + + <div className="grid grid-cols-2 h-[calc(100%-4rem)] overflow-hidden"> + {/* 왼쪽 영역 - 계약서 목록 */} + <div className="col-span-1 border-r border-gray-200 bg-gray-50"> + <div className="p-4 border-b"> + <div className="relative mb-10"> + <div className="absolute inset-y-0 left-3.5 flex items-center pointer-events-none"> + <Search className="h-4 w-8 text-gray-400" /> + </div> + <Input + placeholder="문서명 또는 요청자 검색" + className="bg-white" + style={{paddingLeft:25}} + value={searchTerm} + onChange={(e) => setSearchTerm(e.target.value)} + /> + </div> + <Tabs defaultValue="all" className="w-full"> + <TabsList className="w-full"> + <TabsTrigger value="all" className="flex-1">전체 ({contracts.length})</TabsTrigger> + <TabsTrigger value="contracts" className="flex-1">계약서</TabsTrigger> + <TabsTrigger value="docs" className="flex-1">관련문서</TabsTrigger> + </TabsList> + </Tabs> + </div> + + <ScrollArea className="h-[calc(100%-6rem)]"> + <div className="p-3"> + {filteredContracts.length === 0 ? ( + <div className="flex flex-col items-center justify-center h-40 text-center"> + <FileText className="h-12 w-12 text-gray-300 mb-2" /> + <p className="text-gray-500 font-medium">서명 요청된 문서가 없습니다.</p> + <p className="text-gray-400 text-sm mt-1">나중에 다시 확인해주세요.</p> + </div> + ) : ( + <div className="space-y-2"> + {filteredContracts.map((contract) => ( + <Button + key={contract.id} + variant="outline" + className={cn( + "w-full justify-start text-left h-auto p-3 bg-white hover:bg-blue-50 transition-colors", + "border border-gray-200 hover:border-blue-200 rounded-md", + selectedContract?.id === contract.id && "border-blue-500 bg-blue-50 shadow-sm" + )} + onClick={() => handleSelectContract(contract)} + > + <div className="flex flex-col w-full"> + <div className="flex items-center justify-between w-full"> + <span className="font-semibold truncate text-gray-800 flex items-center"> + <FileText className="h-4 w-4 mr-2 text-blue-500" /> + {contract.templateName || '문서'} + </span> + <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-200"> + 대기중 + </Badge> + </div> + <Separator className="my-2 bg-gray-100" /> + <div className="grid grid-cols-2 gap-1 mt-1 text-xs text-gray-500"> + <div className="flex items-center"> + <User className="h-3 w-3 mr-1" /> + <span className="truncate">{contract.userName || '알 수 없음'}</span> + </div> + <div className="flex items-center justify-end"> + <Calendar className="h-3 w-3 mr-1" /> + <span>{formatDate(contract.createdAt)}</span> + </div> + </div> + </div> + </Button> + ))} + </div> + )} + </div> + </ScrollArea> + </div> + + {/* 오른쪽 영역 - 문서 뷰어 */} + <div className="col-span-1 bg-white flex flex-col h-full"> + {selectedContract ? ( + <> + <div className="p-3 border-b bg-gray-50"> + <h3 className="font-semibold text-gray-800 flex items-center"> + <FileText className="h-4 w-4 mr-2 text-blue-500" /> + {selectedContract.templateName || '문서'} + </h3> + <div className="flex justify-between items-center mt-1 text-xs text-gray-500"> + <span className="flex items-center"> + <User className="h-3 w-3 mr-1" /> + 요청자: {selectedContract.userName || '알 수 없음'} + </span> + <span className="flex items-center"> + <Clock className="h-3 w-3 mr-1" /> + {formatDate(selectedContract.createdAt)} + </span> + </div> + </div> + <div className="flex-grow overflow-hidden border-b"> + <BasicContractSignViewer + contractId={selectedContract.id} + filePath={selectedContract.filePath || undefined} + instance={instance} + setInstance={setInstance} + /> + </div> + <div className="p-3 flex justify-between items-center bg-gray-50"> + <p className="text-sm text-gray-600"> + <AlertCircle className="h-4 w-4 text-yellow-500 inline mr-1" /> + 서명 후에는 변경할 수 없습니다. + </p> + <Button + className="gap-2 bg-blue-600 hover:bg-blue-700 transition-colors" + onClick={completeSign} + disabled={isSubmitting} + > + {isSubmitting ? ( + <> + <svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"> + <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle> + <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> + </svg> + 처리 중... + </> + ) : ( + <> + <FileSignature className="h-4 w-4" /> + 서명 완료 + </> + )} + </Button> + </div> + </> + ) : ( + <div className="flex flex-col items-center justify-center h-full text-center p-6"> + <div className="bg-blue-50 p-6 rounded-full mb-4"> + <FileSignature className="h-12 w-12 text-blue-500" /> + </div> + <h3 className="text-xl font-medium text-gray-800 mb-2">문서를 선택해주세요</h3> + <p className="text-gray-500 max-w-md"> + 왼쪽 목록에서 서명할 문서를 선택하면 여기에 문서 내용이 표시됩니다. + </p> + </div> + )} + </div> + </div> + </DialogContent> + </Dialog> + </> + ); +}
\ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basic-contract-table.tsx b/lib/basic-contract/vendor-table/basic-contract-table.tsx new file mode 100644 index 00000000..34e15ae3 --- /dev/null +++ b/lib/basic-contract/vendor-table/basic-contract-table.tsx @@ -0,0 +1,94 @@ +"use client"; + +import * as React from "react"; +import { DataTable } from "@/components/data-table/data-table"; +import { Button } from "@/components/ui/button"; +import { Plus, Loader2 } from "lucide-react"; +import { useDataTable } from "@/hooks/use-data-table"; +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"; +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" +import { toast } from "sonner"; +import { getColumns } from "./basic-contract-columns"; +import { getBasicContracts, getBasicContractsByVendorId } from "../service"; +import { BasicContractView } from "@/db/schema"; +import { BasicContractTableToolbarActions } from "./basicContract-table-toolbar-actions"; + + +interface BasicTemplateTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getBasicContractsByVendorId>>, + ] + > +} + + +export function BasicContractsVendorTable({ promises }: BasicTemplateTableProps) { + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<BasicContractView> | null>(null) + + + const [{ data, pageCount }] = + React.use(promises) + + // console.log(data) + + // 컬럼 설정 - 외부 파일에서 가져옴 + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // config 기반으로 필터 필드 설정 + const advancedFilterFields: DataTableAdvancedFilterField<BasicContractView>[] = [ + { id: "templateName", label: "템플릿명", type: "text" }, + { + id: "status", label: "상태", type: "select", options: [ + { label: "서명대기", value: "PENDING" }, + { label: "서명완료", value: "COMPLETED" }, + ] + }, + { id: "userName", label: "요청자", type: "text" }, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, + ]; + + const { table } = useDataTable({ + data, + columns, + pageCount, + // filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + > + <BasicContractTableToolbarActions table={table} /> + + </DataTableAdvancedToolbar> + </DataTable> + + </> + + ); +}
\ No newline at end of file diff --git a/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx b/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx new file mode 100644 index 00000000..2e5e4471 --- /dev/null +++ b/lib/basic-contract/vendor-table/basicContract-table-toolbar-actions.tsx @@ -0,0 +1,56 @@ +"use client" + +import * as React from "react" +import { type Task } from "@/db/schema/tasks" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { BasicContractView } from "@/db/schema" +import { BasicContractSignDialog } from "./basic-contract-sign-dialog" + +interface TemplateTableToolbarActionsProps { + table: Table<BasicContractView> +} + +export function BasicContractTableToolbarActions({ table }: TemplateTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + + const inPendingContracts = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(contract => contract.status === "PENDING"); + }, [table.getFilteredSelectedRowModel().rows]); + + + return ( + <div className="flex items-center gap-2"> + + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <BasicContractSignDialog + contracts={inPendingContracts} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "basci-contract-requested-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx new file mode 100644 index 00000000..0409151e --- /dev/null +++ b/lib/basic-contract/viewer/basic-contract-sign-viewer.tsx @@ -0,0 +1,224 @@ +"use client"; + +import React, { + useState, + useEffect, + useRef, + SetStateAction, + Dispatch, +} from "react"; +import { WebViewerInstance } from "@pdftron/webviewer"; +import { Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; + +interface BasicContractSignViewerProps { + contractId?: number; + filePath?: string; + isOpen?: boolean; + onClose?: () => void; + onSign?: (documentData: ArrayBuffer) => Promise<void>; + instance: WebViewerInstance | null; + setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; +} + +export function BasicContractSignViewer({ + contractId, + filePath, + isOpen = false, + onClose, + onSign, + instance, + setInstance, +}: BasicContractSignViewerProps) { + const [fileLoading, setFileLoading] = useState<boolean>(true); + const viewer = useRef<HTMLDivElement>(null); + const initialized = useRef(false); + const isCancelled = useRef(false); + const [showDialog, setShowDialog] = useState(isOpen); + + // 다이얼로그 상태 동기화 + useEffect(() => { + setShowDialog(isOpen); + }, [isOpen]); + + // WebViewer 초기화 + useEffect(() => { + if (!initialized.current && viewer.current) { + initialized.current = true; + isCancelled.current = false; + + requestAnimationFrame(() => { + if (viewer.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨"); + return; + } + + // viewerElement이 확실히 존재함을 확인 + const viewerElement = viewer.current; + if (!viewerElement) return; + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY, + fullAPI: true, + }, + viewerElement + ).then((instance: WebViewerInstance) => { + setInstance(instance); + setFileLoading(false); + + const { disableElements, setToolbarGroup } = instance.UI; + + disableElements([ + "toolbarGroup-Annotate", + "toolbarGroup-Shapes", + "toolbarGroup-Insert", + "toolbarGroup-Edit", + // "toolbarGroup-FillAndSign", + "toolbarGroup-Forms", + ]); + setToolbarGroup("toolbarGroup-View"); + }); + }); + } + }); + } + + return () => { + if (instance) { + instance.UI.dispose(); + } + isCancelled.current = true; + setTimeout(() => cleanupHtmlStyle(), 500); + }; + }, []); + + // 문서 로드 + useEffect(() => { + if (!instance || !filePath) return; + + loadDocument(instance, filePath); + }, [instance, filePath]); + + // 간소화된 문서 로드 함수 + const loadDocument = async (instance: WebViewerInstance, documentPath: string) => { + setFileLoading(true); + try { + const { documentViewer } = instance.Core; + + await documentViewer.loadDocument(documentPath, { extension: 'pdf' }); + + } catch (err) { + console.error("문서 로딩 중 오류 발생:", err); + toast.error("문서를 불러오는데 실패했습니다."); + } finally { + setFileLoading(false); + } + }; + + // 서명 저장 핸들러 + const handleSave = async () => { + if (!instance) return; + + try { + const { documentViewer } = instance.Core; + const doc = documentViewer.getDocument(); + + // 서명된 문서 데이터 가져오기 + const documentData = await doc.getFileData({ + includeAnnotations: true, + }); + + // 외부에서 제공된 onSign 핸들러가 있으면 호출 + if (onSign) { + await onSign(documentData); + } else { + // 기본 동작 - 서명 성공 메시지 표시 + toast.success("계약서가 성공적으로 서명되었습니다."); + } + + handleClose(); + } catch (err) { + console.error("서명 저장 중 오류 발생:", err); + toast.error("서명을 저장하는데 실패했습니다."); + } + }; + + // 다이얼로그 닫기 핸들러 + const handleClose = () => { + if (onClose) { + onClose(); + } else { + setShowDialog(false); + } + }; + + // 인라인 뷰어 렌더링 (다이얼로그 모드가 아닐 때) + if (!isOpen && !onClose) { + return ( + <div className="border rounded-md overflow-hidden" style={{ height: '600px' }}> + <div ref={viewer} className="h-[100%]"> + {fileLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + )} + </div> + </div> + ); + } + + // 다이얼로그 뷰어 렌더링 + return ( + <Dialog open={showDialog} onOpenChange={handleClose}> + <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> + <DialogHeader> + <DialogTitle>기본계약서 서명</DialogTitle> + <DialogDescription> + 계약서를 확인하고 서명을 진행해주세요. + </DialogDescription> + </DialogHeader> + <div className="h-[calc(70vh-60px)]"> + <div ref={viewer} className="h-[100%]"> + {fileLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground">문서 로딩 중...</p> + </div> + )} + </div> + </div> + <DialogFooter> + <Button variant="outline" onClick={handleClose} disabled={fileLoading}> + 취소 + </Button> + <Button onClick={handleSave} disabled={fileLoading}> + 서명 완료 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ); +} + +// WebViewer 정리 함수 +const cleanupHtmlStyle = () => { + // iframe 스타일 정리 (WebViewer가 추가한 스타일) + const elements = document.querySelectorAll('.Document_container'); + elements.forEach((elem) => { + elem.remove(); + }); +};
\ No newline at end of file diff --git a/lib/bidding-projects/repository.ts b/lib/bidding-projects/repository.ts new file mode 100644 index 00000000..44e61553 --- /dev/null +++ b/lib/bidding-projects/repository.ts @@ -0,0 +1,44 @@ +import db from "@/db/db"; +import { biddingProjects } from "@/db/schema"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +export async function selectProjectLists( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } + ) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(biddingProjects) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); + } +/** 총 개수 count */ +export async function countProjectLists( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(biddingProjects).where(where); + return res[0]?.count ?? 0; +} diff --git a/lib/bidding-projects/service.ts b/lib/bidding-projects/service.ts new file mode 100644 index 00000000..569bd18f --- /dev/null +++ b/lib/bidding-projects/service.ts @@ -0,0 +1,117 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { filterColumns } from "@/lib/filter-columns"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { countProjectLists, selectProjectLists } from "./repository"; +import { biddingProjects, ProjectSeries, projectSeries } from "@/db/schema"; +import { GetBidProjectListsSchema } from "./validation"; + +export async function getBidProjectLists(input: GetBidProjectListsSchema) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: biddingProjects, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = or( + ilike(biddingProjects.pspid, s), + ilike(biddingProjects.projNm, s), + ilike(biddingProjects.kunnrNm, s), + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(biddingProjects[item.id]) : asc(biddingProjects[item.id]) + ) + : [asc(biddingProjects.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectProjectLists(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countProjectLists(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["project-lists"], + } + )(); + } + + /** + * 특정 프로젝트의 시리즈 데이터를 가져오는 서버 액션 + * @param pspid 견적프로젝트번호 + * @returns 프로젝트 시리즈 데이터 배열 + */ +export async function getProjectSeriesForProject(pspid: string) { + try { + if (!pspid) { + throw new Error("프로젝트 ID가 제공되지 않았습니다.") + } + + // 트랜잭션을 사용하여 데이터 조회 + const seriesData = await db.transaction(async (tx) => { + const results = await tx + .select() + .from(projectSeries) + .where(eq(projectSeries.pspid, pspid)) + .orderBy(projectSeries.sersNo) + + return results + }) + + + + return seriesData + } catch (error) { + console.error(`프로젝트 시리즈 데이터 가져오기 실패 (pspid: ${pspid}):`, error) + return [] + } +} diff --git a/lib/bidding-projects/table/project-series-dialog.tsx b/lib/bidding-projects/table/project-series-dialog.tsx new file mode 100644 index 00000000..168ede7e --- /dev/null +++ b/lib/bidding-projects/table/project-series-dialog.tsx @@ -0,0 +1,133 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { BiddingProjects } from "@/db/schema" +import { useToast } from "@/hooks/use-toast" + +// Import the function +import { getProjectSeriesForProject } from "../service" + +// Define the ProjectSeries type based on the schema +interface ProjectSeries { + pspid: string; + sersNo: string; + scDt?: string | null; + klDt?: string | null; + lcDt?: string | null; + dlDt?: string | null; + dockNo?: string | null; + dockNm?: string | null; + projNo?: string | null; + post1?: string | null; +} + +interface ProjectSeriesDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + project: BiddingProjects | null +} + +export function ProjectSeriesDialog({ + open, + onOpenChange, + project, +}: ProjectSeriesDialogProps) { + const { toast } = useToast() + + const [projectSeries, setProjectSeries] = React.useState<ProjectSeries[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + React.useEffect(() => { + async function loadItems() { + if (!project?.pspid) return; + + setIsLoading(true) + try { + const result = await getProjectSeriesForProject(project.pspid) + setProjectSeries(result) + } catch (error) { + console.error("프로젝트 시리즈 로드 오류:", error) + toast({ + title: "오류", + description: "프로젝트 시리즈 로드 실패", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + + if (open && project) { + loadItems() + } + }, [toast, project, open]) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[900px]"> + <DialogHeader> + <DialogTitle> + {project ? `시리즈 목록 - ${project.projNm || project.pspid}` : "시리즈 목록"} + </DialogTitle> + </DialogHeader> + {isLoading ? ( + <div className="flex items-center justify-center h-40"> + 로딩 중... + </div> + ) : ( + <div className="max-h-[500px] overflow-y-auto"> + <Table> + <TableHeader> + <TableRow> + <TableHead>시리즈번호</TableHead> + <TableHead>K/L 연도분기</TableHead> + <TableHead>도크코드</TableHead> + <TableHead>도크명</TableHead> + <TableHead>SN공사번호</TableHead> + <TableHead>SN공사명</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {projectSeries && projectSeries.length > 0 ? ( + projectSeries.map((series) => ( + <TableRow key={`${series.pspid}-${series.sersNo}`}> + <TableCell>{series.sersNo}</TableCell> + <TableCell>{series.scDt}</TableCell> + <TableCell>{series.klDt}</TableCell> + <TableCell>{series.lcDt}</TableCell> + <TableCell>{series.dlDt}</TableCell> + <TableCell>{series.dockNo}</TableCell> + <TableCell>{series.dockNm}</TableCell> + <TableCell>{series.projNo}</TableCell> + <TableCell>{series.post1}</TableCell> + </TableRow> + )) + ) : ( + <TableRow> + <TableCell colSpan={6} className="text-center h-24"> + 시리즈 데이터가 없습니다. + </TableCell> + </TableRow> + )} + </TableBody> + </Table> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/bidding-projects/table/projects-table-columns.tsx b/lib/bidding-projects/table/projects-table-columns.tsx new file mode 100644 index 00000000..08530ff0 --- /dev/null +++ b/lib/bidding-projects/table/projects-table-columns.tsx @@ -0,0 +1,102 @@ +"use client" +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { formatDate } from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { BiddingProjects } from "@/db/schema" +import { bidProjectsColumnsConfig } from "@/config/bidProjectsColumnsConfig" +import { Button } from "@/components/ui/button" +import { ListFilter } from "lucide-react" // Import an icon for the button + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<BiddingProjects> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<BiddingProjects>[] { + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<BiddingProjects>[] } + const groupMap: Record<string, ColumnDef<BiddingProjects>[]> = {} + bidProjectsColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + // child column 정의 + const childCol: ColumnDef<BiddingProjects> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + return row.getValue(cfg.id) ?? "" + }, + } + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<BiddingProjects>[] = [] + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // Add action column + const actionColumn: ColumnDef<BiddingProjects> = { + id: "actions", + header: "Actions", + cell: ({ row }) => { + return ( + <Button + variant="ghost" + size="sm" + className="flex items-center gap-1" + onClick={() => { + setRowAction({ row,type: "view-series" }) + }} + > + <ListFilter className="h-4 w-4" /> + 시리즈 보기 + </Button> + ) + }, + } + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: nestedColumns + actions + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + actionColumn, // Add the action column + ] +}
\ No newline at end of file diff --git a/lib/bidding-projects/table/projects-table-toolbar-actions.tsx b/lib/bidding-projects/table/projects-table-toolbar-actions.tsx new file mode 100644 index 00000000..ee2f8c4e --- /dev/null +++ b/lib/bidding-projects/table/projects-table-toolbar-actions.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, RefreshCcw } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { BiddingProjects } from "@/db/schema" + +interface ItemsTableToolbarActionsProps { + table: Table<BiddingProjects> +} + +export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + const [isLoading, setIsLoading] = React.useState(false) + + // 프로젝트 동기화 API 호출 함수 + const syncProjects = async () => { + try { + setIsLoading(true) + + // API 엔드포인트 호출 + const response = await fetch('/api/cron/bid-projects') + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to sync projects') + } + + const data = await response.json() + + // 성공 메시지 표시 + toast.success( + `Projects synced successfully! ${data.result.items} items processed.` + ) + + // 페이지 새로고침으로 테이블 데이터 업데이트 + window.location.reload() + } catch (error) { + console.error('Error syncing projects:', error) + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while syncing projects' + ) + } finally { + setIsLoading(false) + } + } + + return ( + <div className="flex items-center gap-2"> + <Button + variant="samsung" + size="sm" + className="gap-2" + onClick={syncProjects} + disabled={isLoading} + > + <RefreshCcw + className={`size-4 ${isLoading ? 'animate-spin' : ''}`} + aria-hidden="true" + /> + <span className="hidden sm:inline"> + {isLoading ? 'Syncing...' : 'Get Projects'} + </span> + </Button> + + {/** 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "Projects", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + disabled={isLoading} + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/bidding-projects/table/projects-table.tsx b/lib/bidding-projects/table/projects-table.tsx new file mode 100644 index 00000000..0e0c48f9 --- /dev/null +++ b/lib/bidding-projects/table/projects-table.tsx @@ -0,0 +1,156 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} 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 "./projects-table-columns" +import { getBidProjectLists } from "../service" +import { BiddingProjects } from "@/db/schema" +import { ProjectTableToolbarActions } from "./projects-table-toolbar-actions" +import { ProjectSeriesDialog } from "./project-series-dialog" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getBidProjectLists>>, + ] + > +} + +export function BidProjectsTable({ promises }: ItemsTableProps) { + + const [{ data, pageCount }] = + React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<BiddingProjects> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<BiddingProjects>[] = [ + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<BiddingProjects>[] = [ + { + id: "pspid", + label: "견적프로젝트번호", + type: "text", + // group: "Basic Info", + }, + { + id: "projNm", + label: "견적프로젝트명", + type: "text", + // group: "Basic Info", + }, + { + id: "sector", + label: "부문(S / M)", + type: "text", + }, + { + id: "kunnrNm", + label: "선주명", + type: "text", + }, + { + id: "cls1Nm", + label: "선급명", + type: "text", + }, + { + id: "ptypeNm", + label: "선종명", + type: "text", + }, + { + id: "estmPm", + label: "견적대표PM 성명", + type: "text", + }, + { + id: "createdAt", + label: "Created At", + type: "date", + // group: "Metadata",a + }, + { + id: "updatedAt", + label: "Updated At", + type: "date", + // group: "Metadata", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.pspid), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <ProjectTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + + <ProjectSeriesDialog + open={rowAction?.type === "view-series"} + onOpenChange={() => setRowAction(null)} + project={rowAction?.row.original ?? null} + /> + </> + ) +} diff --git a/lib/bidding-projects/validation.ts b/lib/bidding-projects/validation.ts new file mode 100644 index 00000000..e5f8b121 --- /dev/null +++ b/lib/bidding-projects/validation.ts @@ -0,0 +1,32 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { BiddingProjects } from "@/db/schema" + +export const searchParamsBidProjectsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<BiddingProjects>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + +export type GetBidProjectListsSchema = Awaited<ReturnType<typeof searchParamsBidProjectsCache.parse>> diff --git a/lib/cbe/table/cbe-table-columns.tsx b/lib/cbe/table/cbe-table-columns.tsx new file mode 100644 index 00000000..2da62ea8 --- /dev/null +++ b/lib/cbe/table/cbe-table-columns.tsx @@ -0,0 +1,241 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, Ellipsis, MessageSquare } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" + +import { VendorWithCbeFields,vendorCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" + + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> + > + router: NextRouter + openCommentSheet: (responseId: number) => void + openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처 + +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + router, + openCommentSheet, + openVendorContactsDialog +}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { + // ---------------------------------------------------------------- + // 1) Select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorWithCbeFields> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 그룹화(Nested) 컬럼 구성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} + + vendorCbeColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // childCol: ColumnDef<VendorWithTbeFields> + const childCol: ColumnDef<VendorWithCbeFields> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + // 셀 렌더링 + cell: ({ row, getValue }) => { + // 1) 필드값 가져오기 + const val = getValue() + + if (cfg.id === "vendorName") { + const vendor = row.original; + const vendorId = vendor.vendorId; + + // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 + const handleVendorNameClick = () => { + if (vendorId) { + openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 + } else { + toast.error("협력업체 ID를 찾을 수 없습니다."); + } + }; + + return ( + <Button + variant="link" + className="p-0 h-auto text-left font-normal justify-start hover:underline" + onClick={handleVendorNameClick} + > + {val as string} + </Button> + ); + } + + + if (cfg.id === "vendorStatus") { + const statusVal = row.original.vendorStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + return ( + <Badge variant="outline"> + {statusVal} + </Badge> + ) + } + + + if (cfg.id === "responseStatus") { + const statusVal = row.original.responseStatus + if (!statusVal) return null + // const Icon = getStatusIcon(statusVal) + const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" + return ( + <Badge variant={variant}> + {statusVal} + </Badge> + ) + } + + // 예) CBE Updated (날짜) + if (cfg.id === "respondedAt") { + const dateVal = val as Date | undefined + if (!dateVal) return null + return formatDate(dateVal) + } + + // 그 외 필드는 기본 값 표시 + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap → nestedColumns + const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + +// 댓글 칼럼 +const commentsColumn: ColumnDef<VendorWithCbeFields> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const vendor = row.original + const commCount = vendor.comments?.length ?? 0 + + function handleClick() { + // setRowAction() 로 type 설정 + setRowAction({ row, type: "comments" }) + // 필요하면 즉시 openCommentSheet() 직접 호출 + openCommentSheet(vendor.responseId ?? 0) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {commCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {commCount} + </Badge> + )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> + </Button> + ) + }, + enableSorting: false, + minSize: 80, +} +// ---------------------------------------------------------------- +// 5) 최종 컬럼 배열 - Update to include the files column +// ---------------------------------------------------------------- +return [ + selectColumn, + ...nestedColumns, + commentsColumn, + // actionsColumn, +] + +}
\ No newline at end of file diff --git a/lib/cbe/table/cbe-table-toolbar-actions.tsx b/lib/cbe/table/cbe-table-toolbar-actions.tsx new file mode 100644 index 00000000..34b5b46c --- /dev/null +++ b/lib/cbe/table/cbe-table-toolbar-actions.tsx @@ -0,0 +1,72 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { InviteVendorsDialog } from "./invite-vendors-dialog" + +interface VendorsTableToolbarActionsProps { + table: Table<VendorWithCbeFields> + rfqId: number +} + +export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0 + ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))] + : []; + +const hasMultipleRfqIds = uniqueRfqIds.length > 1; + +const invitationPossibeVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.commercialResponseStatus === null); +}, [table.getFilteredSelectedRowModel().rows]); + +return ( + <div className="flex items-center gap-2"> + {invitationPossibeVendors.length > 0 && ( + <InviteVendorsDialog + vendors={invitationPossibeVendors} + rfqId={rfqId} + onSuccess={() => table.toggleAllRowsSelected(false)} + hasMultipleRfqIds={hasMultipleRfqIds} + /> + )} + + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/cbe/table/cbe-table.tsx b/lib/cbe/table/cbe-table.tsx new file mode 100644 index 00000000..38a0a039 --- /dev/null +++ b/lib/cbe/table/cbe-table.tsx @@ -0,0 +1,192 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { toSentenceCase } from "@/lib/utils" +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 "./cbe-table-columns" +import { CommentSheet, CbeComment } from "./comments-sheet" +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { fetchRfqAttachmentsbyCommentId, getAllCBE } from "@/lib/rfqs/service" +import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { VendorContactsDialog } from "@/lib/rfqs/cbe-table/vendor-contact-dialog" +import { useSession } from "next-auth/react" // Next-auth session hook 추가 + + + +import { toast } from "sonner" + +interface VendorsTableProps { + promises: Promise<[ + Awaited<ReturnType<typeof getAllCBE>>, + ]> +} + +export function AllCbeTable({ promises }: VendorsTableProps) { + + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + const { data: session } = useSession() // 세션 정보 가져오기 + + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 + const currentUser = session?.user + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) + // **router** 획득 + const router = useRouter() + // 댓글 시트 관련 state + const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) + const [isLoadingComments, setIsLoadingComments] = React.useState(false) + + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) + const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) + const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null) + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) + + // ----------------------------------------------------------- + // 특정 action이 설정될 때마다 실행되는 effect + // ----------------------------------------------------------- + React.useEffect(() => { + if (rowAction?.type === "comments") { + // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 + openCommentSheet(Number(rowAction.row.original.responseId)) + } + }, [rowAction]) + + // ----------------------------------------------------------- + // 댓글 시트 열기 + // ----------------------------------------------------------- + async function openCommentSheet(responseId: number) { + setInitialComments([]) + setIsLoadingComments(true) + const comments = rowAction?.row.original.comments + const rfqId = rowAction?.row.original.rfqId + const vendorId = rowAction?.row.original.vendorId + try { + if (comments && comments.length > 0) { + const commentWithAttachments: CbeComment[] = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + + return { + ...c, + commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 + setInitialComments(commentWithAttachments) + } + + if(vendorId){ setSelectedVendorId(vendorId)} + if(rfqId){ setSelectedRfqId(rfqId)} + setSelectedCbeId(responseId) + setCommentSheetOpen(true) + }catch (error) { + console.error("Error loading comments:", error) + toast.error("Failed to load comments") + } finally { + // End loading regardless of success/failure + setIsLoadingComments(false) + } +} + +const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => { + setSelectedVendorId(vendorId) + setSelectedVendor(vendor) + setIsContactDialogOpen(true) +} + + // ----------------------------------------------------------- + // 테이블 컬럼 + // ----------------------------------------------------------- + const columns = React.useMemo( + () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }), + [setRowAction, router] + ) + + // ----------------------------------------------------------- + // 필터 필드 + // ----------------------------------------------------------- + const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [ + // 예: 표준 필터 + ] + const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ + { id: "vendorName", label: "Vendor Name", type: "text" }, + { id: "vendorCode", label: "Vendor Code", type: "text" }, + { id: "respondedAt", label: "Updated at", type: "date" }, + ] + + // ----------------------------------------------------------- + // 테이블 생성 훅 + // ----------------------------------------------------------- + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "respondedAt", desc: true }], + columnPinning: { right: ["comments"] }, + }, + getRowId: (originalRow) => (`${originalRow.vendorId}${originalRow.rfqId}`), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <VendorsTableToolbarActions table={table} rfqId={selectedRfqId ?? 0} /> + </DataTableAdvancedToolbar> + </DataTable> + + {/* 댓글 시트 */} + <CommentSheet + currentUserId={currentUserId} + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + vendorId={selectedVendorId ?? 0} + rfqId={selectedRfqId ?? 0} + cbeId={selectedCbeId ?? 0} + isLoading={isLoadingComments} + initialComments={initialComments} + /> + + <InviteVendorsDialog + vendors={rowAction?.row.original ? [rowAction?.row.original] : []} + onOpenChange={() => setRowAction(null)} + rfqId={selectedRfqId ?? 0} + open={rowAction?.type === "invite"} + showTrigger={false} + currentUser={currentUser} + /> + + <VendorContactsDialog + isOpen={isContactDialogOpen} + onOpenChange={setIsContactDialogOpen} + vendorId={selectedVendorId} + vendor={selectedVendor} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/cbe/table/comments-sheet.tsx b/lib/cbe/table/comments-sheet.tsx new file mode 100644 index 00000000..2ac9049f --- /dev/null +++ b/lib/cbe/table/comments-sheet.tsx @@ -0,0 +1,345 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Download, X ,Loader2} from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Textarea, +} from "@/components/ui/textarea" + +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" + +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell +} from "@/components/ui/table" + +// DB 스키마에서 필요한 타입들을 가져온다고 가정 +// (실제 프로젝트에 맞춰 import를 수정하세요.) +import { formatDate } from "@/lib/utils" +import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" + +// 코멘트 + 첨부파일 구조 (단순 예시) +// 실제 DB 스키마에 맞춰 조정 +export interface CbeComment { + id: number + commentText: string + commentedBy?: number + commentedByEmail?: string + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + initialComments?: CbeComment[] + currentUserId: number + rfqId: number + // tbeId?: number + cbeId?: number + vendorId: number + onCommentsUpdated?: (comments: CbeComment[]) => void + isLoading?: boolean // New prop +} + +// 새 코멘트 작성 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional() // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + // tbeId, + cbeId, + onCommentsUpdated, + isLoading = false, // Default to false + ...props +}: CommentSheetProps) { + const [comments, setComments] = React.useState<CbeComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + + // RHF 세팅 + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [] + } + }) + + // formFieldArray 예시 (파일 목록) + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles" + }) + + // (A) 기존 코멘트 렌더링 + function renderExistingComments() { + + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {/* 첨부파일 표시 */} + {!c.attachments?.length && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments?.length && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell> + {c.commentedByEmail ?? "-"} + </TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // 2) 새 파일 Drop + function handleDropAccepted(files: File[]) { + append(files) + } + + + // 3) 저장(Submit) + async function onSubmit(data: CommentFormValues) { + + if (!rfqId) return + startTransition(async () => { + try { + // console.log("rfqId", rfqId) + // console.log("vendorId", vendorId) + // console.log("cbeId", cbeId) + // console.log("currentUserId", currentUserId) + const res = await createRfqCommentWithAttachments({ + rfqId: rfqId, + vendorId: vendorId, // 필요시 세팅 + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, // 필요시 세팅 + cbeId: cbeId, + files: data.newFiles + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 새 코멘트를 다시 불러오거나, + // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + const newComment: CbeComment = { + id: res.commentId, // 서버에서 반환된 commentId + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: (data.newFiles?.map((f, idx) => ({ + id: Math.random() * 100000, + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || []) + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + // 폼 리셋 + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + {/* 기존 코멘트 목록 */} + <div className="max-h-[300px] overflow-y-auto"> + {renderExistingComments()} + </div> + + {/* 새 코멘트 작성 Form */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea + placeholder="Enter your comment..." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Dropzone (파일 첨부) */} + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {/* 선택된 파일 목록 */} + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div key={field.id} className="flex items-center justify-between border rounded p-2"> + <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/cbe/table/invite-vendors-dialog.tsx b/lib/cbe/table/invite-vendors-dialog.tsx new file mode 100644 index 00000000..6ebc087b --- /dev/null +++ b/lib/cbe/table/invite-vendors-dialog.tsx @@ -0,0 +1,428 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Send, User } from "lucide-react" +import { toast } from "sonner" +import { z } from "zod" + +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 { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { type Row } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { createCbeEvaluation } from "@/lib/rfqs/service" + +// 컴포넌트 내부에서 사용할 폼 스키마 정의 +const formSchema = z.object({ + paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), + incoterms: z.string().min(1, "Incoterms를 입력하세요"), + deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), + notes: z.string().optional(), +}) + +type FormValues = z.infer<typeof formSchema> + +interface InviteVendorsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + rfqId: number + vendors: Row<VendorWithCbeFields>["original"][] + currentUserId?: number + currentUser?: { + id: string + name?: string | null + email?: string | null + image?: string | null + companyId?: number | null + domain?: string | null + } + showTrigger?: boolean + onSuccess?: () => void + hasMultipleRfqIds?: boolean +} + +export function InviteVendorsDialog({ + rfqId, + vendors, + currentUserId, + currentUser, + showTrigger = true, + onSuccess, + hasMultipleRfqIds, + ...props +}: InviteVendorsDialogProps) { + const [files, setFiles] = React.useState<FileList | null>(null) + const isDesktop = useMediaQuery("(min-width: 640px)") + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 로컬 스키마와 폼 값을 사용하도록 수정 + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + paymentTerms: "", + incoterms: "", + deliverySchedule: "", + notes: "", + }, + mode: "onChange", + }) + + // 폼 상태 감시 + const { formState } = form + const isValid = formState.isValid && + !!form.getValues("paymentTerms") && + !!form.getValues("incoterms") && + !!form.getValues("deliverySchedule") + + // 디버깅용 상태 트래킹 + React.useEffect(() => { + const subscription = form.watch((value) => { + // 폼 값이 변경될 때마다 실행되는 콜백 + console.log("Form values changed:", value); + }); + + return () => subscription.unsubscribe(); + }, [form]); + + async function onSubmit(data: FormValues) { + try { + setIsSubmitting(true) + + // 기본 FormData 생성 + const formData = new FormData() + + // rfqId 추가 + formData.append("rfqId", String(rfqId)) + + // 폼 데이터 추가 + Object.entries(data).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + formData.append(key, String(value)) + } + }) + + // 현재 사용자 ID 추가 + if (currentUserId) { + formData.append("evaluatedBy", String(currentUserId)) + } + + // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회) + vendors.forEach((vendor) => { + formData.append("vendorIds[]", String(vendor.vendorId)) + }) + + // 파일 추가 (있는 경우에만) + if (files && files.length > 0) { + for (let i = 0; i < files.length; i++) { + formData.append("files", files[i]) + } + } + + // 서버 액션 호출 + const response = await createCbeEvaluation(formData) + + if (response.error) { + toast.error(response.error) + return + } + + // 성공 처리 + toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`) + form.reset() + setFiles(null) + props.onOpenChange?.(false) + onSuccess?.() + } catch (error) { + console.error(error) + toast.error("CBE 평가 생성 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setFiles(null) + } + props.onOpenChange?.(nextOpen) + } + + // 필수 필드 라벨에 추가할 요소 + const RequiredLabel = ( + <span className="text-destructive ml-1 font-medium">*</span> + ) + + const formContent = ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 선택된 협력업체 정보 표시 */} + <div className="space-y-2"> + <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel> + <ScrollArea className="h-20 border rounded-md p-2"> + <div className="flex flex-wrap gap-2"> + {vendors.map((vendor, index) => ( + <Badge key={index} variant="secondary" className="py-1"> + {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} + </Badge> + ))} + </div> + </ScrollArea> + <FormDescription> + 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다. + </FormDescription> + </div> + + {/* 작성자 정보 (읽기 전용) */} + {currentUser && ( + <div className="border rounded-md p-3 space-y-2"> + <FormLabel>작성자</FormLabel> + <div className="flex items-center gap-3"> + {currentUser.image ? ( + <Avatar className="h-8 w-8"> + <AvatarImage src={currentUser.image} alt={currentUser.name || ""} /> + <AvatarFallback> + {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} + </AvatarFallback> + </Avatar> + ) : ( + <Avatar className="h-8 w-8"> + <AvatarFallback> + {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} + </AvatarFallback> + </Avatar> + )} + <div> + <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p> + <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p> + </div> + </div> + </div> + )} + + {/* 결제 조건 - 필수 필드 */} + <FormField + control={form.control} + name="paymentTerms" + render={({ field }) => ( + <FormItem> + <FormLabel> + 결제 조건{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: Net 30" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Incoterms - 필수 필드 */} + <FormField + control={form.control} + name="incoterms" + render={({ field }) => ( + <FormItem> + <FormLabel> + Incoterms{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: FOB, CIF" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 배송 일정 - 필수 필드 */} + <FormField + control={form.control} + name="deliverySchedule" + render={({ field }) => ( + <FormItem> + <FormLabel> + 배송 일정{RequiredLabel} + </FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="배송 일정 세부사항을 입력하세요" + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 비고 - 선택적 필드 */} + <FormField + control={form.control} + name="notes" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="추가 비고 사항을 입력하세요" + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 첨부 (옵션) */} + <div className="space-y-2"> + <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel> + <Input + id="files" + type="file" + multiple + onChange={(e) => setFiles(e.target.files)} + /> + {files && files.length > 0 && ( + <p className="text-sm text-muted-foreground"> + {files.length}개 파일이 첨부되었습니다 + </p> + )} + </div> + + {/* 필수 입력 항목 안내 */} + <div className="text-sm text-muted-foreground"> + <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다. + </div> + + {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */} + {isDesktop && ( + <DialogFooter className="gap-2 pt-4"> + <DialogClose asChild> + <Button + type="button" + variant="outline" + > + 취소 + </Button> + </DialogClose> + <Button + type="submit" + disabled={isSubmitting || !isValid} + > + {isSubmitting && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} + </Button> + </DialogFooter> + )} + </form> + </Form> + ) + if (hasMultipleRfqIds) { + toast.error("동일한 RFQ에 대해 선택해주세요"); + return; + } + // Desktop Dialog + if (isDesktop) { + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + CBE 평가 전송 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>CBE 평가 생성 및 전송</DialogTitle> + <DialogDescription> + 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. + </DialogDescription> + </DialogHeader> + + {formContent} + </DialogContent> + </Dialog> + ) + } + + // Mobile Drawer + return ( + <Drawer {...props} onOpenChange={handleDialogOpenChange}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + CBE 평가 전송 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle> + <DrawerDescription> + 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. + </DrawerDescription> + </DrawerHeader> + + <div className="px-4"> + {formContent} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + onClick={form.handleSubmit(onSubmit)} + disabled={isSubmitting || !isValid} + > + {isSubmitting && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/equip-class/service.ts b/lib/equip-class/service.ts index deaacc58..91b165f4 100644 --- a/lib/equip-class/service.ts +++ b/lib/equip-class/service.ts @@ -11,7 +11,6 @@ import { countTagClassLists, selectTagClassLists } from "./repository"; import { projects } from "@/db/schema"; export async function getTagClassists(input: GetTagClassesSchema) { - return unstable_cache( async () => { try { diff --git a/lib/form-list/repository.ts b/lib/form-list/repository.ts index d3c555bf..9c7f6891 100644 --- a/lib/form-list/repository.ts +++ b/lib/form-list/repository.ts @@ -36,6 +36,8 @@ export async function selectFormLists( classLabel: tagTypeClassFormMappings.classLabel, formCode: tagTypeClassFormMappings.formCode, formName: tagTypeClassFormMappings.formName, + ep: tagTypeClassFormMappings.ep, + remark: tagTypeClassFormMappings.remark, createdAt: tagTypeClassFormMappings.createdAt, updatedAt: tagTypeClassFormMappings.updatedAt, // 프로젝트 정보 추가 diff --git a/lib/form-list/service.ts b/lib/form-list/service.ts index 310930be..9887609f 100644 --- a/lib/form-list/service.ts +++ b/lib/form-list/service.ts @@ -74,6 +74,8 @@ export async function getFormLists(input: GetFormListsSchema) { offset, limit: input.perPage, }); + + console.log("dbdata") const total = await countFormLists(tx, where); return { data, total }; diff --git a/lib/form-list/table/formLists-table-toolbar-actions.tsx b/lib/form-list/table/formLists-table-toolbar-actions.tsx index 96494607..cf874985 100644 --- a/lib/form-list/table/formLists-table-toolbar-actions.tsx +++ b/lib/form-list/table/formLists-table-toolbar-actions.tsx @@ -2,71 +2,150 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, RefreshCcw, Upload } from "lucide-react" +import { Download, RefreshCcw } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" import { ExtendedFormMappings } from "../validation" - - interface ItemsTableToolbarActionsProps { table: Table<ExtendedFormMappings> } export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { const [isLoading, setIsLoading] = React.useState(false) + const [syncId, setSyncId] = React.useState<string | null>(null) + const pollingRef = React.useRef<NodeJS.Timeout | null>(null) + + // Clean up polling on unmount + React.useEffect(() => { + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current) + } + } + }, []) - const syncForms = async () => { + const startFormSync = async () => { try { setIsLoading(true) - - // API 엔드포인트 호출 - const response = await fetch('/api/cron/forms') - + + // API 엔드포인트 호출 - 작업 시작만 요청 + const response = await fetch('/api/cron/forms/start', { + method: 'POST' + }) + if (!response.ok) { const errorData = await response.json() - throw new Error(errorData.error || 'Failed to sync forms') + throw new Error(errorData.error || 'Failed to start form sync') } - + const data = await response.json() - - // 성공 메시지 표시 - toast.success( - `Forms synced successfully! ${data.result.items} items processed.` - ) - - // 페이지 새로고침으로 테이블 데이터 업데이트 - window.location.reload() + + // 작업 ID 저장 + if (data.syncId) { + setSyncId(data.syncId) + toast.info('Form sync started. This may take a while...') + + // 상태 확인을 위한 폴링 시작 + startPolling(data.syncId) + } else { + throw new Error('No sync ID returned from server') + } } catch (error) { - console.error('Error syncing forms:', error) + console.error('Error starting form sync:', error) toast.error( error instanceof Error ? error.message - : 'An error occurred while syncing forms' + : 'An error occurred while starting form sync' ) - } finally { setIsLoading(false) } } - + + const startPolling = (id: string) => { + // 이전 폴링이 있다면 제거 + if (pollingRef.current) { + clearInterval(pollingRef.current) + } + + // 5초마다 상태 확인 + pollingRef.current = setInterval(async () => { + try { + const response = await fetch(`/api/cron/forms/status?id=${id}`) + + if (!response.ok) { + throw new Error('Failed to get sync status') + } + + const data = await response.json() + + if (data.status === 'completed') { + // 폴링 중지 + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + + // 상태 초기화 + setIsLoading(false) + setSyncId(null) + + // 성공 메시지 표시 + toast.success( + `Forms synced successfully! ${data.result?.items || 0} items processed.` + ) + + // 테이블 데이터 업데이트 - 전체 페이지 새로고침 대신 데이터만 갱신 + table.resetRowSelection() + // 여기서 테이블 데이터를 다시 불러오는 함수를 호출 + // 예: refetchTableData() + // 또는 SWR/React Query 등을 사용하고 있다면 mutate() 호출 + + // 리로드가 꼭 필요하다면 아래 주석 해제 + // window.location.reload() + } else if (data.status === 'failed') { + // 에러 처리 + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + + setIsLoading(false) + setSyncId(null) + toast.error(data.error || 'Sync failed') + } else if (data.status === 'processing') { + // 진행 상태 업데이트 (선택적) + if (data.progress) { + toast.info(`Sync in progress: ${data.progress}%`, { + id: `sync-progress-${id}`, + }) + } + } + } catch (error) { + console.error('Error checking sync status:', error) + } + }, 5000) // 5초마다 체크 + } return ( <div className="flex items-center gap-2"> - {/** 4) Export 버튼 */} + {/** Sync Forms 버튼 */} <Button variant="samsung" size="sm" className="gap-2" + onClick={startFormSync} + disabled={isLoading} > <RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" /> <span className="hidden sm:inline"> {isLoading ? 'Syncing...' : 'Get Forms'} </span> </Button> - - {/** 4) Export 버튼 */} + + {/** Export 버튼 */} <Button variant="outline" size="sm" diff --git a/lib/form-list/table/formLists-table.tsx b/lib/form-list/table/formLists-table.tsx index 58ac4671..aa5bfa09 100644 --- a/lib/form-list/table/formLists-table.tsx +++ b/lib/form-list/table/formLists-table.tsx @@ -32,7 +32,6 @@ export function FormListsTable({ promises }: ItemsTableProps) { const [{ data, pageCount }] = React.use(promises) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<ExtendedFormMappings> | null>(null) @@ -100,6 +99,12 @@ export function FormListsTable({ promises }: ItemsTableProps) { type: "text", }, + { + id: "remark", + label: "remark", + type: "text", + + }, { id: "createdAt", diff --git a/lib/form-list/table/meta-sheet.tsx b/lib/form-list/table/meta-sheet.tsx index 694ee845..7c15bdea 100644 --- a/lib/form-list/table/meta-sheet.tsx +++ b/lib/form-list/table/meta-sheet.tsx @@ -1,8 +1,9 @@ "use client" import * as React from "react" -import { useMemo } from "react" +import { useState } from "react" import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" import { Sheet, SheetContent, @@ -34,6 +35,56 @@ import { import type { TagTypeClassFormMappings } from "@/db/schema/vendorData" // or your actual type import { fetchFormMetadata, FormColumn } from "@/lib/forms/services" +// 옵션을 표시하기 위한 새로운 컴포넌트 +const CollapsibleOptions = ({ options }: { options?: string[] }) => { + const [isExpanded, setIsExpanded] = useState(false); + + // 옵션이 없거나 5개 이하인 경우 모두 표시 + if (!options || options.length <= 5) { + return ( + <div className="flex flex-wrap gap-1"> + {options?.map((option) => ( + <Badge key={option} variant="outline" className="text-xs"> + {option} + </Badge> + )) || "-"} + </div> + ); + } + + // 더 많은 옵션이 있는 경우 접었다 펼칠 수 있게 함 + return ( + <div> + <div className="flex flex-wrap gap-1 mb-1"> + {isExpanded + ? options.map((option) => ( + <Badge key={option} variant="outline" className="text-xs"> + {option} + </Badge> + )) + : options.slice(0, 3).map((option) => ( + <Badge key={option} variant="outline" className="text-xs"> + {option} + </Badge> + )) + } + {!isExpanded && ( + <Badge variant="outline" className="text-xs bg-muted"> + +{options.length - 3} more + </Badge> + )} + </div> + <Button + variant="ghost" + size="sm" + className="h-6 text-xs px-2 py-0" + onClick={() => setIsExpanded(!isExpanded)} + > + {isExpanded ? "Show less" : "Show all"} + </Button> + </div> + ); +}; interface ViewMetasProps { open: boolean @@ -51,7 +102,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { const [loading, setLoading] = React.useState(false) // Group columns by type for better organization - const groupedColumns = useMemo(() => { + const groupedColumns = React.useMemo(() => { if (!metadata?.columns) return {} return metadata.columns.reduce((acc, column) => { @@ -65,7 +116,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { }, [metadata]) // Types for the tabs - const columnTypes = useMemo(() => { + const columnTypes = React.useMemo(() => { return Object.keys(groupedColumns) }, [groupedColumns]) @@ -165,17 +216,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { <Badge variant="secondary">{column.type}</Badge> </TableCell> <TableCell> - {column.options ? ( - <div className="flex flex-wrap gap-1"> - {column.options.map((option) => ( - <Badge key={option} variant="outline" className="text-xs"> - {option} - </Badge> - ))} - </div> - ) : ( - "-" - )} + <CollapsibleOptions options={column.options} /> </TableCell> </TableRow> ))} @@ -198,7 +239,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { <TableRow> <TableHead>Key</TableHead> <TableHead>Label</TableHead> - {type === "select" && <TableHead>Options</TableHead>} + {type === "LIST" && <TableHead>Options</TableHead>} </TableRow> </TableHeader> <TableBody> @@ -206,19 +247,9 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { <TableRow key={column.key}> <TableCell className="font-mono text-sm">{column.key}</TableCell> <TableCell>{column.label}</TableCell> - {type === "select" && ( + {type === "LIST" && ( <TableCell> - {column.options ? ( - <div className="flex flex-wrap gap-1"> - {column.options.map((option) => ( - <Badge key={option} variant="outline" className="text-xs"> - {option} - </Badge> - ))} - </div> - ) : ( - "-" - )} + <CollapsibleOptions options={column.options} /> </TableCell> )} </TableRow> diff --git a/lib/forms/services.ts b/lib/forms/services.ts index d77f91d3..bd6e4bbc 100644 --- a/lib/forms/services.ts +++ b/lib/forms/services.ts @@ -10,74 +10,78 @@ import { formEntries, formMetas, forms, + tagClasses, tags, + tagSubfieldOptions, + tagSubfields, tagTypeClassFormMappings, + tagTypes, vendorDataReportTemps, VendorDataReportTemps, } from "@/db/schema/vendorData"; -import { eq, and, desc, sql, DrizzleError, or } from "drizzle-orm"; +import { eq, and, desc, sql, DrizzleError, or,type SQL ,type InferSelectModel } from "drizzle-orm"; import { unstable_cache } from "next/cache"; import { revalidateTag } from "next/cache"; import { getErrorMessage } from "../handle-error"; import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns"; import { contractItems, contracts, projects } from "@/db/schema"; +import { getSEDPToken } from "../sedp/sedp-token"; -export interface FormInfo { - id: number; - formCode: string; - formName: string; - // tagType: string -} -export async function getFormsByContractItemId(contractItemId: number | null) { +export type FormInfo = InferSelectModel<typeof forms>; +export async function getFormsByContractItemId( + contractItemId: number | null, + mode: "ENG" | "IM" | "ALL" = "ALL" +): Promise<{ forms: FormInfo[] }> { // 유효성 검사 if (!contractItemId || contractItemId <= 0) { console.warn(`Invalid contractItemId: ${contractItemId}`); return { forms: [] }; } - // 고유 캐시 키 - const cacheKey = `forms-${contractItemId}`; + // 고유 캐시 키 (모드 포함) + const cacheKey = `forms-${contractItemId}-${mode}`; try { return unstable_cache( - async () => { - console.log(contractItemId,"contractItemId") - console.log( - `[Forms Service] Fetching forms for contractItemId: ${contractItemId}` + `[Forms Service] Fetching forms for contractItemId: ${contractItemId}, mode: ${mode}` ); try { - // 데이터베이스에서 폼 조회 - const formRecords = await db - .select({ - id: forms.id, - formCode: forms.formCode, - formName: forms.formName, - // tagType: forms.tagType, - }) - .from(forms) - .where(eq(forms.contractItemId, contractItemId)); + // 쿼리 생성 + let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId)); + + // 모드에 따른 추가 필터 + if (mode === "ENG") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.eng, true) + ) + ); + } else if (mode === "IM") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.im, true) + ) + ); + } + + // 쿼리 실행 + const formRecords = await query; console.log( - `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}` + `[Forms Service] Found ${formRecords.length} forms for contractItemId: ${contractItemId}, mode: ${mode}` ); - // 결과가 배열인지 확인 - if (!Array.isArray(formRecords)) { - getErrorMessage( - `Unexpected result format for contractItemId ${contractItemId} ${formRecords}` - ); - return { forms: [] }; - } - return { forms: formRecords }; } catch (error) { getErrorMessage( - `Database error for contractItemId ${contractItemId}: ${error}` + `Database error for contractItemId ${contractItemId}, mode: ${mode}: ${error}` ); throw error; // 캐시 함수에서 에러를 던져 캐싱이 발생하지 않도록 함 } @@ -91,29 +95,42 @@ export async function getFormsByContractItemId(contractItemId: number | null) { )(); } catch (error) { getErrorMessage( - `Cache operation failed for contractItemId ${contractItemId}: ${error}` + `Cache operation failed for contractItemId ${contractItemId}, mode: ${mode}: ${error}` ); // 캐시 문제 시 직접 쿼리 시도 try { console.log( - `[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}` + `[Forms Service] Fallback: Direct query for contractItemId: ${contractItemId}, mode: ${mode}` ); - const formRecords = await db - .select({ - id: forms.id, - formCode: forms.formCode, - formName: forms.formName, - // tagType: forms.tagType, - }) - .from(forms) - .where(eq(forms.contractItemId, contractItemId)); + // 쿼리 생성 + let query = db.select().from(forms).where(eq(forms.contractItemId, contractItemId)); + + // 모드에 따른 추가 필터 + if (mode === "ENG") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.eng, true) + ) + ); + } else if (mode === "IM") { + query = db.select().from(forms).where( + and( + eq(forms.contractItemId, contractItemId), + eq(forms.im, true) + ) + ); + } + + // 쿼리 실행 + const formRecords = await query; return { forms: formRecords }; } catch (dbError) { getErrorMessage( - `Fallback query failed for contractItemId ${contractItemId}:${dbError}` + `Fallback query failed for contractItemId ${contractItemId}, mode: ${mode}: ${dbError}` ); return { forms: [] }; } @@ -145,6 +162,7 @@ export async function revalidateForms(contractItemId: number) { export async function getFormData(formCode: string, contractItemId: number) { // 고유 캐시 키 (formCode + contractItemId) const cacheKey = `form-data-${formCode}-${contractItemId}`; + console.log(cacheKey, "getFormData") try { // 1) unstable_cache로 전체 로직을 감싼다 @@ -338,88 +356,6 @@ export async function getFormData(formCode: string, contractItemId: number) { } } -// export async function syncMissingTags(contractItemId: number, formCode: string) { - -// // (1) forms 테이블에서 (contractItemId, formCode) 찾기 -// const [formRow] = await db -// .select() -// .from(forms) -// .where(and(eq(forms.contractItemId, contractItemId), eq(forms.formCode, formCode))) -// .limit(1) - -// if (!formRow) { -// throw new Error(`Form not found for contractItemId=${contractItemId}, formCode=${formCode}`) -// } - -// const { tagType, class: className } = formRow - -// // (2) tags 테이블에서 (contractItemId, tagType, class)인 태그 찾기 -// const tagRows = await db -// .select() -// .from(tags) -// .where( -// and( -// eq(tags.contractItemId, contractItemId), -// eq(tags.tagType, tagType), -// eq(tags.class, className), -// ) -// ) - -// if (tagRows.length === 0) { -// console.log("No matching tags found.") -// return { createdCount: 0 } -// } - -// // (3) formEntries에서 (contractItemId, formCode)인 row 1개 조회 -// let [entry] = await db -// .select() -// .from(formEntries) -// .where( -// and( -// eq(formEntries.contractItemId, contractItemId), -// eq(formEntries.formCode, formCode) -// ) -// ) -// .limit(1) - -// // (4) 만약 없다면 새로 insert: data = [] -// if (!entry) { -// const [inserted] = await db.insert(formEntries).values({ -// contractItemId, -// formCode, -// data: [], // 초기 상태는 빈 배열 -// }).returning() -// entry = inserted -// } - -// // entry.data는 배열이라고 가정 -// // Drizzle에서 jsonb는 JS object로 파싱되어 들어오므로, 타입 캐스팅 -// const existingData = entry.data as Array<{ tagNumber: string }> -// let createdCount = 0 - -// // (5) tagRows 각각에 대해, 이미 배열에 존재하는지 확인 후 없으면 push -// const updatedArray = [...existingData] -// for (const tagRow of tagRows) { -// const tagNo = tagRow.tagNo -// const found = updatedArray.some(item => item.tagNumber === tagNo) -// if (!found) { -// updatedArray.push({ tagNumber: tagNo }) -// createdCount++ -// } -// } - -// // (6) 변경이 있으면 UPDATE -// if (createdCount > 0) { -// await db -// .update(formEntries) -// .set({ data: updatedArray }) -// .where(eq(formEntries.id, entry.id)) -// } - -// revalidateTag(`form-data-${formCode}-${contractItemId}`); - -// return { createdCount } -// } export async function syncMissingTags( contractItemId: number, @@ -490,10 +426,10 @@ export async function syncMissingTags( entry = inserted; } - // entry.data는 [{ tagNumber: string, tagDescription?: string }, ...] 형태라고 가정 + // entry.data는 [{ TAG_NO: string, TAG_DESC?: string }, ...] 형태라고 가정 const existingData = entry.data as Array<{ - tagNumber: string; - tagDescription?: string; + TAG_NO: string; + TAG_DESC?: string; }>; // Create a Set of valid tagNumbers from tagRows for efficient lookup @@ -501,8 +437,8 @@ export async function syncMissingTags( // Copy existing data to work with let updatedData: Array<{ - tagNumber: string; - tagDescription?: string; + TAG_NO: string; + TAG_DESC?: string; }> = []; let createdCount = 0; @@ -511,7 +447,7 @@ export async function syncMissingTags( // First, filter out items that should be deleted (not in validTagNumbers) for (const item of existingData) { - if (validTagNumbers.has(item.tagNumber)) { + if (validTagNumbers.has(item.TAG_NO)) { updatedData.push(item); } else { deletedCount++; @@ -523,25 +459,25 @@ export async function syncMissingTags( for (const tagRow of tagRows) { const { tagNo, description } = tagRow; - // 5-1. 기존 데이터에서 tagNumber 매칭 + // 5-1. 기존 데이터에서 TAG_NO 매칭 const existingIndex = updatedData.findIndex( - (item) => item.tagNumber === tagNo + (item) => item.TAG_NO === tagNo ); // 5-2. 없다면 새로 추가 if (existingIndex === -1) { updatedData.push({ - tagNumber: tagNo, - tagDescription: description ?? "", + TAG_NO: tagNo, + TAG_DESC: description ?? "", }); createdCount++; } else { // 5-3. 이미 있으면, description이 다를 때만 업데이트(선택 사항) const existingItem = updatedData[existingIndex]; - if (existingItem.tagDescription !== description) { + if (existingItem.TAG_DESC !== description) { updatedData[existingIndex] = { ...existingItem, - tagDescription: description ?? "", + TAG_DESC: description ?? "", }; updatedCount++; } @@ -565,7 +501,7 @@ export async function syncMissingTags( /** * updateFormDataInDB: * (formCode, contractItemId)에 해당하는 "단 하나의" formEntries row를 가져와, - * data: [{ tagNumber, ...}, ...] 배열에서 tagNumber 매칭되는 항목을 업데이트 + * data: [{ TAG_NO, ...}, ...] 배열에서 TAG_NO 매칭되는 항목을 업데이트 * 업데이트 후, revalidateTag()로 캐시 무효화. */ type UpdateResponse = { @@ -581,8 +517,8 @@ export async function updateFormDataInDB( ): Promise<UpdateResponse> { try { // 1) tagNumber로 식별 - const tagNumber = newData.tagNumber; - if (!tagNumber) { + const TAG_NO = newData.TAG_NO; + if (!TAG_NO) { return { success: false, message: "tagNumber는 필수 항목입니다.", @@ -626,12 +562,12 @@ export async function updateFormDataInDB( }; } - // 4) tagNumber = newData.tagNumber 항목 찾기 - const idx = dataArray.findIndex((item) => item.tagNumber === tagNumber); + // 4) TAG_NO = newData.TAG_NO 항목 찾기 + const idx = dataArray.findIndex((item) => item.TAG_NO === TAG_NO); if (idx < 0) { return { success: false, - message: `태그 번호 "${tagNumber}"를 가진 항목을 찾을 수 없습니다.`, + message: `태그 번호 "${TAG_NO}"를 가진 항목을 찾을 수 없습니다.`, }; } @@ -640,7 +576,7 @@ export async function updateFormDataInDB( const updatedItem = { ...oldItem, ...newData, - tagNumber: oldItem.tagNumber, // tagNumber 변경 불가 시 유지 + TAG_NO: oldItem.TAG_NO, // TAG_NO 변경 불가 시 유지 }; const updatedArray = [...dataArray]; @@ -675,6 +611,7 @@ export async function updateFormDataInDB( try { // 캐시 태그를 form-data-${formCode}-${contractItemId} 형태로 가정 const cacheTag = `form-data-${formCode}-${contractItemId}`; + console.log(cacheTag, "update") revalidateTag(cacheTag); } catch (cacheError) { console.warn("Cache revalidation warning:", cacheError); @@ -685,9 +622,9 @@ export async function updateFormDataInDB( success: true, message: "데이터가 성공적으로 업데이트되었습니다.", data: { - tagNumber, + TAG_NO, updatedFields: Object.keys(newData).filter( - (key) => key !== "tagNumber" + (key) => key !== "TAG_NO" ), }, }; @@ -922,3 +859,405 @@ export const deleteReportTempFile: deleteReportTempFile = async (id) => { return { result: false, error: (err as Error).message }; } }; + + +/** + * Get tag type mappings specific to a form + * @param formCode The form code to filter mappings + * @param projectId The project ID + * @returns Array of tag type-class mappings for the form + */ +export async function getFormTagTypeMappings(formCode: string, projectId: number) { + + try { + const mappings = await db.query.tagTypeClassFormMappings.findMany({ + where: and( + eq(tagTypeClassFormMappings.formCode, formCode), + eq(tagTypeClassFormMappings.projectId, projectId) + ) + }); + + return mappings; + } catch (error) { + console.error("Error fetching form tag type mappings:", error); + throw new Error("Failed to load form tag type mappings"); + } +} + +/** + * Get tag type by its description + * @param description The tag type description (used as tagTypeLabel in mappings) + * @param projectId The project ID + * @returns The tag type object + */ +export async function getTagTypeByDescription(description: string, projectId: number) { + try { + const tagType = await db.query.tagTypes.findFirst({ + where: and( + eq(tagTypes.description, description), + eq(tagTypes.projectId, projectId) + ) + }); + + return tagType; + } catch (error) { + console.error("Error fetching tag type by description:", error); + throw new Error("Failed to load tag type"); + } +} + +/** + * Get subfields for a specific tag type + * @param tagTypeCode The tag type code + * @param projectId The project ID + * @returns Object containing subfields with their options + */ +export async function getSubfieldsByTagTypeForForm(tagTypeCode: string, projectId: number) { + try { + const subfields = await db.query.tagSubfields.findMany({ + where: and( + eq(tagSubfields.tagTypeCode, tagTypeCode), + eq(tagSubfields.projectId, projectId) + ), + orderBy: tagSubfields.sortOrder + }); + + const subfieldsWithOptions = await Promise.all( + subfields.map(async (subfield) => { + const options = await db.query.tagSubfieldOptions.findMany({ + where: and( + eq(tagSubfieldOptions.attributesId, subfield.attributesId), + eq(tagSubfieldOptions.projectId, projectId) + ) + }); + + return { + name: subfield.attributesId, + label: subfield.attributesDescription, + type: options.length > 0 ? "select" : "text", + options: options.map(opt => ({ value: opt.code, label: opt.label })), + expression: subfield.expression || undefined, + delimiter: subfield.delimiter || undefined + }; + }) + ); + + return { subFields: subfieldsWithOptions }; + } catch (error) { + console.error("Error fetching subfields for form:", error); + throw new Error("Failed to load subfields"); + } +} + +interface GenericData { + [key: string]: any; +} + +interface SEDPAttribute { + NAME: string; + VALUE: any; + UOM: string; + UOM_ID?: string; +} + +interface SEDPDataItem { + TAG_NO: string; + TAG_DESC: string; + ATTRIBUTES: SEDPAttribute[]; + SCOPE: string; + TOOLID: string; + ITM_NO: string; + OP_DELETE: boolean; + MAIN_YN: boolean; + LAST_REV_YN: boolean; + CRTER_NO: string; + CHGER_NO: string; + TYPE: string; + PROJ_NO: string; + REV_NO: string; + CRTE_DTM?: string; + CHGE_DTM?: string; + _id?: string; +} + +async function transformDataToSEDPFormat( + tableData: GenericData[], + columnsJSON: DataTableColumnJSON[], + formCode: string, + objectCode: string, + projectNo: string, + designerNo: string = "253213" +): Promise<SEDPDataItem[]> { + // Create a map for quick column lookup + const columnsMap = new Map<string, DataTableColumnJSON>(); + columnsJSON.forEach(col => { + columnsMap.set(col.key, col); + }); + + // Current timestamp for CRTE_DTM and CHGE_DTM + const currentTimestamp = new Date().toISOString(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + // Get the token + const apiKey = await getSEDPToken(); + + // Cache for UOM factors to avoid duplicate API calls + const uomFactorCache = new Map<string, number>(); + + // Transform each row + const transformedItems = []; + + for (const row of tableData) { + // Create base SEDP item with required fields + const sedpItem: SEDPDataItem = { + TAG_NO: row.TAG_NO || "", + TAG_DESC: row.TAG_DESC || "", + ATTRIBUTES: [], + SCOPE: objectCode, + TOOLID: "eVCP", // Changed from VDCS + ITM_NO: row.TAG_NO || "", + OP_DELETE: false, + MAIN_YN: true, + LAST_REV_YN: true, + CRTER_NO: designerNo, + CHGER_NO: designerNo, + TYPE: formCode, + PROJ_NO: projectNo, + REV_NO: "00", + CRTE_DTM: currentTimestamp, + CHGE_DTM: currentTimestamp, + _id: "" + }; + + // Convert all other fields (except TAG_NO and TAG_DESC) to ATTRIBUTES + for (const key in row) { + if (key !== "TAG_NO" && key !== "TAG_DESC") { + const column = columnsMap.get(key); + let value = row[key]; + + // Only process non-empty values + if (value !== undefined && value !== null && value !== "") { + // Check if we need to apply UOM conversion + if (column?.uomId) { + // First check cache to avoid duplicate API calls + let factor = uomFactorCache.get(column.uomId); + + // If not in cache, make API call to get the factor + if (factor === undefined) { + try { + const response = await fetch( + `${SEDP_API_BASE_URL}/UOM/GetByID`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectNo + }, + body: JSON.stringify({ + 'ProjectNo': projectNo, + 'UOMID': column.uomId, + 'ContainDeleted': false + }) + } + ); + + if (response.ok) { + const uomData = await response.json(); + if (uomData && uomData.FACTOR !== undefined && uomData.FACTOR !== null) { + factor = Number(uomData.FACTOR); + // Store in cache for future use (type assertion to ensure it's a number) + uomFactorCache.set(column.uomId, factor); + } + } else { + console.warn(`Failed to get UOM data for ${column.uomId}: ${response.statusText}`); + } + } catch (error) { + console.error(`Error fetching UOM data for ${column.uomId}:`, error); + } + } + + // Apply the factor if we got one + if (factor !== undefined && typeof value === 'number') { + value = value * factor; + } + } + + const attribute: SEDPAttribute = { + NAME: key, + VALUE: String(value), // 모든 값을 문자열로 변환 + UOM: column?.uom || "" + }; + + // Add UOM_ID if present in column definition + if (column?.uomId) { + attribute.UOM_ID = column.uomId; + } + + sedpItem.ATTRIBUTES.push(attribute); + } + } + } + + transformedItems.push(sedpItem); + } + + return transformedItems; +} + +// Server Action wrapper (async) +export async function transformFormDataToSEDP( + tableData: GenericData[], + columnsJSON: DataTableColumnJSON[], + formCode: string, + objectCode: string, + projectNo: string, + designerNo: string = "253213" +): Promise<SEDPDataItem[]> { + // Use the utility function within the async Server Action + return transformDataToSEDPFormat( + tableData, + columnsJSON, + formCode, + objectCode, + projectNo, + designerNo + ); +} + +/** + * Get project code by project ID + */ +export async function getProjectCodeById(projectId: number): Promise<string> { + const projectRecord = await db + .select({ code: projects.code }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (!projectRecord || projectRecord.length === 0) { + throw new Error(`Project not found with ID: ${projectId}`); + } + + return projectRecord[0].code; +} + +/** + * Send data to SEDP + */ +export async function sendDataToSEDP( + projectCode: string, + sedpData: SEDPDataItem[] +): Promise<any> { + try { + // Get the token + const apiKey = await getSEDPToken(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + console.log("Sending data to SEDP:", JSON.stringify(sedpData, null, 2)); + + // Make the API call + const response = await fetch( + `${SEDP_API_BASE_URL}/AdapterData/Create`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify(sedpData) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data; + } catch (error: any) { + console.error('Error calling SEDP API:', error); + throw new Error(`Failed to send data to SEDP API: ${error.message || 'Unknown error'}`); + } +} + +/** + * Server action to send form data to SEDP + */ +export async function sendFormDataToSEDP( + formCode: string, + projectId: number, + formData: GenericData[], + columns: DataTableColumnJSON[] +): Promise<{ success: boolean; message: string; data?: any }> { + try { + // 1. Get project code + const projectCode = await getProjectCodeById(projectId); + + // 2. Get class mapping + const mappingsResult = await db.query.tagTypeClassFormMappings.findFirst({ + where: and( + eq(tagTypeClassFormMappings.formCode, formCode), + eq(tagTypeClassFormMappings.projectId, projectId) + ) + }); + + // Check if mappings is an array or a single object and handle accordingly + const mappings = Array.isArray(mappingsResult) ? mappingsResult[0] : mappingsResult; + + // Default object code to fallback value if we can't find it + let objectCode = ""; // Default fallback + + if (mappings && mappings.classLabel) { + const objectCodeResult = await db.query.tagClasses.findFirst({ + where: and( + eq(tagClasses.label, mappings.classLabel), + eq(tagClasses.projectId, projectId) + ) + }); + + // Check if result is an array or a single object + const objectCodeRecord = Array.isArray(objectCodeResult) ? objectCodeResult[0] : objectCodeResult; + + if (objectCodeRecord && objectCodeRecord.code) { + objectCode = objectCodeRecord.code; + } else { + console.warn(`No tag class found for label ${mappings.classLabel} in project ${projectId}, using default`); + } + } else { + console.warn(`No mapping found for formCode ${formCode} in project ${projectId}, using default object code`); + } + + // 4. Transform data to SEDP format + const sedpData = await transformFormDataToSEDP( + formData, + columns, + formCode, + objectCode, + projectCode + ); + + // 5. Send to SEDP API + const result = await sendDataToSEDP(projectCode, sedpData); + + return { + success: true, + message: "Data successfully sent to SEDP", + data: result + }; + } catch (error: any) { + console.error("Error sending data to SEDP:", error); + return { + success: false, + message: error.message || "Failed to send data to SEDP" + }; + } +}
\ No newline at end of file diff --git a/lib/items/service.ts b/lib/items/service.ts index ef14a5f0..226742ca 100644 --- a/lib/items/service.ts +++ b/lib/items/service.ts @@ -9,7 +9,7 @@ import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or ,eq} from "drizzle-orm"; import { CreateItemSchema, GetItemsSchema, UpdateItemSchema } from "./validations"; import { Item, items } from "@/db/schema/items"; import { countItems, deleteItemById, deleteItemsByIds, findAllItems, insertItem, selectItems, updateItem } from "./repository"; @@ -102,32 +102,86 @@ export async function getItems(input: GetItemsSchema) { /* ----------------------------------------------------- 2) 생성(Create) ----------------------------------------------------- */ +export interface ItemCreateData { + itemCode: string + itemName: string + description: string | null +} /** * Item 생성 후, (가장 오래된 Item 1개) 삭제로 * 전체 Item 개수를 고정 */ -export async function createItem(input: CreateItemSchema) { - unstable_noStore(); // Next.js 서버 액션 캐싱 방지 +export async function createItem(input: ItemCreateData) { + unstable_noStore() // Next.js 서버 액션 캐싱 방지 + try { - await db.transaction(async (tx) => { - // 새 Item 생성 - const [newTask] = await insertItem(tx, { - itemCode: input.itemCode, - itemName: input.itemName, - description: input.description, - }); - return newTask; + if (!input.itemCode || !input.itemName) { + return { + success: false, + message: "아이템 코드와 아이템 명은 필수입니다", + data: null, + error: "필수 필드 누락" + } + } - }); + // result 변수에 명시적으로 타입과 초기값 할당 + let result: any[] = [] + + // 트랜잭션 결과를 result에 할당 + result = await db.transaction(async (tx) => { + // 기존 아이템 확인 (itemCode는 unique) + const existingItem = await tx.query.items.findFirst({ + where: eq(items.itemCode, input.itemCode), + }) + + let txResult + if (existingItem) { + // 기존 아이템 업데이트 + txResult = await updateItem(tx, existingItem.id, { + itemName: input.itemName, + description: input.description, + }) + } else { + // 새 아이템 생성 + txResult = await insertItem(tx, { + itemCode: input.itemCode, + itemName: input.itemName, + description: input.description, + }) + } + + return txResult + }) // 캐시 무효화 - revalidateTag("items"); + revalidateTag("items") - return { data: null, error: null }; + return { + success: true, + data: result[0] || null, + error: null + } } catch (err) { - return { data: null, error: getErrorMessage(err) }; + console.error("아이템 생성/업데이트 오류:", err) + + // 중복 키 오류 처리 + if (err instanceof Error && err.message.includes("unique constraint")) { + return { + success: false, + message: "이미 존재하는 아이템 코드입니다", + data: null, + error: "중복 키 오류" + } + } + + return { + success: false, + message: getErrorMessage(err), + data: null, + error: getErrorMessage(err) + } } } diff --git a/lib/items/table/import-excel-button.tsx b/lib/items/table/import-excel-button.tsx new file mode 100644 index 00000000..484fd778 --- /dev/null +++ b/lib/items/table/import-excel-button.tsx @@ -0,0 +1,266 @@ +"use client" + +import * as React from "react" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import * as ExcelJS from 'exceljs' + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { processFileImport } from "./import-item-handler" // 별도 파일로 분리 + +interface ImportItemButtonProps { + onSuccess?: () => void +} + +export function ImportItemButton({ onSuccess }: ImportItemButtonProps) { + const [open, setOpen] = React.useState(false) + const [file, setFile] = React.useState<File | null>(null) + const [isUploading, setIsUploading] = React.useState(false) + const [progress, setProgress] = React.useState(0) + const [error, setError] = React.useState<string | null>(null) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (!selectedFile) return + + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.") + return + } + + setFile(selectedFile) + setError(null) + } + + // 데이터 가져오기 처리 + const handleImport = async () => { + if (!file) { + setError("가져올 파일을 선택해주세요.") + return + } + + try { + setIsUploading(true) + setProgress(0) + setError(null) + + // 파일을 ArrayBuffer로 읽기 + const arrayBuffer = await file.arrayBuffer(); + + // ExcelJS 워크북 로드 + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); + + // 첫 번째 워크시트 가져오기 + const worksheet = workbook.worksheets[0]; + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다."); + } + + // 헤더 행 찾기 + let headerRowIndex = 1; + let headerRow: ExcelJS.Row | undefined; + let headerValues: (string | null)[] = []; + + worksheet.eachRow((row, rowNumber) => { + const values = row.values as (string | null)[]; + if (!headerRow && values.some(v => v === "아이템 코드" || v === "itemCode" || v === "item_code")) { + headerRowIndex = rowNumber; + headerRow = row; + headerValues = [...values]; + } + }); + + if (!headerRow) { + throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); + } + + // 헤더를 기반으로 인덱스 매핑 생성 + const headerMapping: Record<string, number> = {}; + headerValues.forEach((value, index) => { + if (typeof value === 'string') { + headerMapping[value] = index; + } + }); + + // 필수 헤더 확인 + const requiredHeaders = ["아이템 코드", "아이템 명", "설명"]; + const alternativeHeaders = { + "아이템 코드": ["itemCode", "item_code"], + "아이템 명": ["itemName", "item_name"], + "설명": ["description"] + }; + + // 헤더 매핑 확인 (대체 이름 포함) + const missingHeaders = requiredHeaders.filter(header => { + const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || []; + return !(header in headerMapping) && + !alternatives.some(alt => alt in headerMapping); + }); + + if (missingHeaders.length > 0) { + throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); + } + + // 데이터 행 추출 (헤더 이후 행부터) + const dataRows: Record<string, any>[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record<string, any> = {}; + const values = row.values as (string | null | undefined)[]; + + // 헤더 매핑에 따라 데이터 추출 + Object.entries(headerMapping).forEach(([header, index]) => { + rowData[header] = values[index] || ""; + }); + + // 빈 행이 아닌 경우만 추가 + if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { + dataRows.push(rowData); + } + } + }); + + if (dataRows.length === 0) { + throw new Error("Excel 파일에 가져올 데이터가 없습니다."); + } + + // 진행 상황 업데이트를 위한 콜백 + const updateProgress = (current: number, total: number) => { + const percentage = Math.round((current / total) * 100); + setProgress(percentage); + }; + + // 실제 데이터 처리는 별도 함수에서 수행 + const result = await processFileImport( + dataRows, + updateProgress + ); + + // 처리 완료 + toast.success(`${result.successCount}개의 아이템이 성공적으로 가져와졌습니다.`); + + if (result.errorCount > 0) { + toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`); + } + + // 상태 초기화 및 다이얼로그 닫기 + setFile(null); + setOpen(false); + + // 성공 콜백 호출 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("Excel 파일 처리 중 오류 발생:", error); + setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // 닫을 때 상태 초기화 + setFile(null) + setError(null) + setProgress(0) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + setOpen(newOpen) + } + + return ( + <> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={() => setOpen(true)} + disabled={isUploading} + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>아이템 가져오기</DialogTitle> + <DialogDescription> + 아이템을 Excel 파일에서 가져옵니다. + <br /> + 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + <div className="flex items-center gap-4"> + <input + type="file" + ref={fileInputRef} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isUploading} + /> + </div> + + {file && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB) + </div> + )} + + {isUploading && ( + <div className="space-y-2"> + <Progress value={progress} /> + <p className="text-sm text-muted-foreground text-center"> + {progress}% 완료 + </p> + </div> + )} + + {error && ( + <div className="text-sm font-medium text-destructive"> + {error} + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setOpen(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!file || isUploading} + > + {isUploading ? "처리 중..." : "가져오기"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/items/table/import-item-handler.tsx b/lib/items/table/import-item-handler.tsx new file mode 100644 index 00000000..541d6fe1 --- /dev/null +++ b/lib/items/table/import-item-handler.tsx @@ -0,0 +1,118 @@ +"use client" + +import { z } from "zod" +import { createItem } from "../service" // 아이템 생성 서버 액션 + +// 아이템 데이터 검증을 위한 Zod 스키마 +const itemSchema = z.object({ + itemCode: z.string().min(1, "아이템 코드는 필수입니다"), + itemName: z.string().min(1, "아이템 명은 필수입니다"), + description: z.string().nullable().optional(), +}); + +interface ProcessResult { + successCount: number; + errorCount: number; + errors?: Array<{ row: number; message: string }>; +} + +/** + * Excel 파일에서 가져온 아이템 데이터 처리하는 함수 + */ +export async function processFileImport( + jsonData: any[], + progressCallback?: (current: number, total: number) => void +): Promise<ProcessResult> { + // 결과 카운터 초기화 + let successCount = 0; + let errorCount = 0; + const errors: Array<{ row: number; message: string }> = []; + + // 빈 행 등 필터링 + const dataRows = jsonData.filter(row => { + // 빈 행 건너뛰기 + if (Object.values(row).every(val => !val)) { + return false; + } + return true; + }); + + // 데이터 행이 없으면 빈 결과 반환 + if (dataRows.length === 0) { + return { successCount: 0, errorCount: 0 }; + } + + // 각 행에 대해 처리 + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 + + // 진행 상황 콜백 호출 + if (progressCallback) { + progressCallback(i + 1, dataRows.length); + } + + try { + // 필드 매핑 (한글/영문 필드명 모두 지원) + const itemCode = row["아이템 코드"] || row["itemCode"] || row["item_code"] || ""; + const itemName = row["아이템 명"] || row["itemName"] || row["item_name"] || ""; + const description = row["설명"] || row["description"] || null; + + // 데이터 정제 + const cleanedRow = { + itemCode: typeof itemCode === 'string' ? itemCode.trim() : String(itemCode).trim(), + itemName: typeof itemName === 'string' ? itemName.trim() : String(itemName).trim(), + description: description ? (typeof description === 'string' ? description : String(description)) : null, + }; + + // 데이터 유효성 검사 + const validationResult = itemSchema.safeParse(cleanedRow); + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors.map( + err => `${err.path.join('.')}: ${err.message}` + ).join(', '); + + errors.push({ row: rowIndex, message: errorMessage }); + errorCount++; + continue; + } + + // 아이템 생성 서버 액션 호출 + const result = await createItem({ + itemCode: cleanedRow.itemCode, + itemName: cleanedRow.itemName, + description: cleanedRow.description, + }); + + if (result.success || !result.error) { + successCount++; + } else { + errors.push({ + row: rowIndex, + message: result.message || result.error || "알 수 없는 오류" + }); + errorCount++; + } + } catch (error) { + console.error(`${rowIndex}행 처리 오류:`, error); + errors.push({ + row: rowIndex, + message: error instanceof Error ? error.message : "알 수 없는 오류" + }); + errorCount++; + } + + // 비동기 작업 쓰로틀링 + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + // 처리 결과 반환 + return { + successCount, + errorCount, + errors: errors.length > 0 ? errors : undefined + }; +}
\ No newline at end of file diff --git a/lib/items/table/item-excel-template.tsx b/lib/items/table/item-excel-template.tsx new file mode 100644 index 00000000..75338168 --- /dev/null +++ b/lib/items/table/item-excel-template.tsx @@ -0,0 +1,94 @@ +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + +/** + * 아이템 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportItemTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('아이템'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '아이템 코드', key: 'itemCode', width: 15 }, + { header: '아이템 명', key: 'itemName', width: 30 }, + { header: '설명', key: 'description', width: 50 } + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 테두리 스타일 적용 + headerRow.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { itemCode: 'ITEM001', itemName: '샘플 아이템 1', description: '이것은 샘플 아이템 1의 설명입니다.' }, + { itemCode: 'ITEM002', itemName: '샘플 아이템 2', description: '이것은 샘플 아이템 2의 설명입니다.' } + ]; + + // 데이터 행 추가 + sampleData.forEach(item => { + worksheet.addRow(item); + }); + + // 데이터 행 스타일 적용 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // 헤더를 제외한 데이터 행 + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + } + }); + + // 워크시트 보호 (선택적) + worksheet.protect('', { + selectLockedCells: true, + selectUnlockedCells: true, + formatColumns: true, + formatRows: true, + insertColumns: false, + insertRows: true, + insertHyperlinks: false, + deleteColumns: false, + deleteRows: true, + sort: true, + autoFilter: true, + pivotTables: false + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, 'item-template.xlsx'); + return true; + } catch (error) { + console.error('Excel 템플릿 생성 오류:', error); + throw error; + } +}
\ No newline at end of file diff --git a/lib/items/table/items-table-columns.tsx b/lib/items/table/items-table-columns.tsx index 60043e8e..8dd84c58 100644 --- a/lib/items/table/items-table-columns.tsx +++ b/lib/items/table/items-table-columns.tsx @@ -26,9 +26,6 @@ import { } from "@/components/ui/dropdown-menu" import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" -import { modifiTask } from "@/lib/tasks/service" - - import { itemsColumnsConfig } from "@/config/itemsColumnsConfig" import { Item } from "@/db/schema/items" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" diff --git a/lib/items/table/items-table-toolbar-actions.tsx b/lib/items/table/items-table-toolbar-actions.tsx index 3444daab..b3178ce1 100644 --- a/lib/items/table/items-table-toolbar-actions.tsx +++ b/lib/items/table/items-table-toolbar-actions.tsx @@ -2,37 +2,119 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload } from "lucide-react" -import { toast } from "sonner" +import { Download, FileDown } from "lucide-react" +import * as ExcelJS from 'exceljs' +import { saveAs } from "file-saver" -import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" - -// 만약 서버 액션이나 API 라우트를 이용해 업로드 처리한다면 import -import { importTasksExcel } from "@/lib/tasks/service" // 예시 import { Item } from "@/db/schema/items" import { DeleteItemsDialog } from "./delete-items-dialog" import { AddItemDialog } from "./add-items-dialog" +import { exportItemTemplate } from "./item-excel-template" +import { ImportItemButton } from "./import-excel-button" interface ItemsTableToolbarActionsProps { table: Table<Item> } export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { - // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 - const fileInputRef = React.useRef<HTMLInputElement>(null) - + const [refreshKey, setRefreshKey] = React.useState(0) + // 가져오기 성공 후 테이블 갱신 + const handleImportSuccess = () => { + setRefreshKey(prev => prev + 1) + } - function handleImportClick() { - // 숨겨진 <input type="file" /> 요소를 클릭 - fileInputRef.current?.click() + // Excel 내보내기 함수 + const exportTableToExcel = async ( + table: Table<any>, + options: { + filename: string; + excludeColumns?: string[]; + sheetName?: string; + } + ) => { + const { filename, excludeColumns = [], sheetName = "아이템 목록" } = options; + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet(sheetName); + + // 테이블 데이터 가져오기 + const data = table.getFilteredRowModel().rows.map(row => row.original); + + // 테이블 헤더 가져오기 + const headers = table.getAllColumns() + .filter(column => !excludeColumns.includes(column.id)) + .map(column => ({ + key: column.id, + header: column.columnDef.header?.toString() || column.id + })); + + // 컬럼 정의 + worksheet.columns = headers.map(header => ({ + header: header.header, + key: header.key, + width: 20 // 기본 너비 + })); + + // 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 데이터 행 추가 + data.forEach(item => { + const row: Record<string, any> = {}; + headers.forEach(header => { + row[header.key] = item[header.key]; + }); + worksheet.addRow(row); + }); + + // 전체 셀에 테두리 추가 + worksheet.eachRow((row, rowNumber) => { + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, `${filename}.xlsx`); + return true; + } catch (error) { + console.error("Excel 내보내기 오류:", error); + return false; + } } return ( <div className="flex items-center gap-2"> - {/** 1) 선택된 로우가 있으면 삭제 다이얼로그 */} + {/* 선택된 로우가 있으면 삭제 다이얼로그 */} {table.getFilteredSelectedRowModel().rows.length > 0 ? ( <DeleteItemsDialog items={table @@ -42,26 +124,39 @@ export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProp /> ) : null} - {/** 2) 새 Task 추가 다이얼로그 */} + {/* 새 아이템 추가 다이얼로그 */} <AddItemDialog /> - + {/* Import 버튼 */} + <ImportItemButton onSuccess={handleImportSuccess} /> - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "tasks", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> + {/* Export 드롭다운 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => + exportTableToExcel(table, { + filename: "items", + excludeColumns: ["select", "actions"], + sheetName: "아이템 목록" + }) + } + > + <FileDown className="mr-2 h-4 w-4" /> + <span>현재 데이터 내보내기</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => exportItemTemplate()}> + <FileDown className="mr-2 h-4 w-4" /> + <span>템플릿 다운로드</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ) }
\ No newline at end of file diff --git a/lib/mail/layouts/base.hbs b/lib/mail/layouts/base.hbs new file mode 100644 index 00000000..2e18f035 --- /dev/null +++ b/lib/mail/layouts/base.hbs @@ -0,0 +1,22 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <title>{{subject}}</title> + </head> + <body style="margin:0; padding:20px; background-color:#f5f5f5; font-family:Arial, sans-serif; color:#111827;"> + <table width="100%" cellpadding="0" cellspacing="0" border="0" align="center"> + <tr> + <td align="center"> + <table width="600" cellpadding="0" cellspacing="0" border="0" style="background-color:#ffffff; border:1px solid #e5e7eb; border-radius:6px; padding:24px;"> + <tr> + <td> + {{{body}}} + </td> + </tr> + </table> + </td> + </tr> + </table> + </body> +</html> diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts index 200a0ed9..3474a373 100644 --- a/lib/mail/mailer.ts +++ b/lib/mail/mailer.ts @@ -15,14 +15,45 @@ const transporter = nodemailer.createTransport({ }, }); -// Handlebars 템플릿 로더 함수 -function loadTemplate(templateName: string, data: Record<string, any>) { +// // Handlebars 템플릿 로더 함수 +// function loadTemplate(templateName: string, data: Record<string, any>) { +// const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`); +// const source = fs.readFileSync(templatePath, 'utf8'); +// const template = handlebars.compile(source); +// return template(data); +// } +function applyLayout(layoutName: string, content: string, context: Record<string, any>) { + const layoutPath = path.join(process.cwd(), 'lib', 'mail', 'layouts', `${layoutName}.hbs`); + const layoutSource = fs.readFileSync(layoutPath, 'utf8'); + const layoutTemplate = handlebars.compile(layoutSource); + return layoutTemplate({ ...context, body: content }); +} + +// Partials 자동 등록 +function registerPartials() { + const partialsDir = path.join(process.cwd(), 'lib', 'mail', 'partials'); + const filenames = fs.readdirSync(partialsDir); + + filenames.forEach((filename) => { + const name = path.parse(filename).name; + const filepath = path.join(partialsDir, filename); + const source = fs.readFileSync(filepath, 'utf8'); + handlebars.registerPartial(name, source); // {{> header }}, {{> footer }} + }); +} + + +// 템플릿 불러오기 + layout/partials 적용 +function loadTemplate(templateName: string, context: Record<string, any>, layout = 'base') { + registerPartials(); + const templatePath = path.join(process.cwd(), 'lib', 'mail', 'templates', `${templateName}.hbs`); const source = fs.readFileSync(templatePath, 'utf8'); const template = handlebars.compile(source); - return template(data); -} + const content = template(context); // 본문 먼저 처리 + return applyLayout(layout, content, context); // base.hbs로 감싸기 +} handlebars.registerHelper('t', function(key: string, options: any) { // options.hash에는 Handlebars에서 넘긴 named parameter들(location=location 등)이 들어있음 return i18next.t(key, options.hash || {}); diff --git a/lib/mail/partials/footer.hbs b/lib/mail/partials/footer.hbs new file mode 100644 index 00000000..06aae57d --- /dev/null +++ b/lib/mail/partials/footer.hbs @@ -0,0 +1,8 @@ +<table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;"> + <tr> + <td align="center"> + <p style="font-size:16px; color:#6b7280; margin:4px 0;">© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p> + <p style="font-size:16px; color:#6b7280; margin:4px 0;">{{t "email.vendor.invitation.no_reply"}}</p> + </td> + </tr> +</table> diff --git a/lib/mail/partials/header.hbs b/lib/mail/partials/header.hbs new file mode 100644 index 00000000..7898c82e --- /dev/null +++ b/lib/mail/partials/header.hbs @@ -0,0 +1,7 @@ +<table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;"> + <tr> + <td align="center"> + <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span> + </td> + </tr> +</table> diff --git a/lib/mail/templates/admin-created.hbs b/lib/mail/templates/admin-created.hbs index 7be7f15d..3db6c433 100644 --- a/lib/mail/templates/admin-created.hbs +++ b/lib/mail/templates/admin-created.hbs @@ -1,78 +1,25 @@ -<!DOCTYPE html> -<html> - <head> - <meta charset="utf-8" /> - <title>{{t "adminCreated.title" lng=language}}</title> - <style> - /* 간단한 스타일 예시 */ - body { - font-family: Arial, sans-serif; - margin: 0; - padding: 16px; - background-color: #f5f5f5; - } - .container { - max-width: 600px; - margin: 0 auto; - background-color: #ffffff; - padding: 24px; - border-radius: 8px; - } - h1 { - font-size: 20px; - margin-bottom: 16px; - } - p { - font-size: 14px; - line-height: 1.6; - } - .btn { - display: inline-block; - margin-top: 16px; - padding: 12px 24px; - background-color: #1D4ED8; - color: #ffffff !important; - text-decoration: none; - border-radius: 4px; - } - .footer { - margin-top: 24px; - font-size: 12px; - color: #888888; - } - </style> - </head> - <body> - <div class="container"> - <!-- 상단 로고/타이틀 영역 --> - <div style="text-align: center;"> - <!-- 필요 시 로고 이미지 --> - <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> --> - </div> +{{> header logoUrl=logoUrl }} - <h1>{{t "adminCreated.title" lng=language}}</h1> +<h1 style="font-size:28px; margin-bottom:16px;"> + {{t "adminCreated.title" lng=language}} +</h1> - <p> - {{t "adminCreated.greeting" lng=language}}, <strong>{{name}}</strong>. - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{t "adminCreated.greeting" lng=language}}, <strong>{{name}}</strong>. +</p> - <p> - {{t "adminCreated.body1" lng=language}} - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{t "adminCreated.body1" lng=language}} +</p> - <p> - <a class="btn" href="{{loginUrl}}" target="_blank">{{t "adminCreated.loginCTA" lng=language}}</a> - </p> +<p> + <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin-top: 16px;"> + {{t "adminCreated.loginCTA" lng=language}} + </a> +</p> - <p> - {{t "adminCreated.supportMsg" lng=language}} - </p> +<p style="font-size:16px; line-height:24px; margin-top:16px;"> + {{t "adminCreated.supportMsg" lng=language}} +</p> - <div class="footer"> - <p> - {{t "adminCreated.footerDisclaimer" lng=language}} - </p> - </div> - </div> - </body> -</html>
\ No newline at end of file +{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file diff --git a/lib/mail/templates/admin-email-changed.hbs b/lib/mail/templates/admin-email-changed.hbs index 7b8ca473..fb88feab 100644 --- a/lib/mail/templates/admin-email-changed.hbs +++ b/lib/mail/templates/admin-email-changed.hbs @@ -1,90 +1,30 @@ -<!DOCTYPE html> -<html> - <head> - <meta charset="utf-8" /> - <title>{{t "adminEmailChanged.title" lng=language}}</title> - <style> - /* 간단한 스타일 예시 */ - body { - font-family: Arial, sans-serif; - margin: 0; - padding: 16px; - background-color: #f5f5f5; - } - .container { - max-width: 600px; - margin: 0 auto; - background-color: #ffffff; - padding: 24px; - border-radius: 8px; - } - h1 { - font-size: 20px; - margin-bottom: 16px; - } - p { - font-size: 14px; - line-height: 1.6; - } - .btn { - display: inline-block; - margin-top: 16px; - padding: 12px 24px; - background-color: #1D4ED8; - color: #ffffff !important; - text-decoration: none; - border-radius: 4px; - } - .footer { - margin-top: 24px; - font-size: 12px; - color: #888888; - } - </style> - </head> - <body> - <div class="container"> - <!-- 상단 로고/타이틀 영역 --> - <div style="text-align: center;"> - <!-- 필요 시 로고 이미지 --> - <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> --> - </div> +{{> header logoUrl=logoUrl }} - <!-- 메일 제목 --> - <h1>{{t "adminEmailChanged.title" lng=language}}</h1> +<h1 style="font-size:28px; margin-bottom:16px;"> + {{t "adminEmailChanged.title" lng=language}} +</h1> - <!-- 인사말 --> - <p> - {{t "adminEmailChanged.greeting" lng=language}}, <strong>{{name}}</strong>. - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{t "adminEmailChanged.greeting" lng=language}}, <strong>{{name}}</strong>. +</p> - <!-- 이전 이메일 / 새 이메일 안내 --> - <p> - {{t "adminEmailChanged.body.intro" lng=language}} - </p> - <p> - <strong>{{t "adminEmailChanged.body.oldEmail" lng=language}}:</strong> {{oldEmail}}<br /> - <strong>{{t "adminEmailChanged.body.newEmail" lng=language}}:</strong> {{newEmail}} - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:8px;"> + {{t "adminEmailChanged.body.intro" lng=language}} +</p> - <!-- 버튼(로그인 / 대시보드 등) --> - <p> - <a class="btn" href="{{loginUrl}}" target="_blank"> - {{t "adminEmailChanged.loginCTA" lng=language}} - </a> - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + <strong>{{t "adminEmailChanged.body.oldEmail" lng=language}}:</strong> {{oldEmail}}<br /> + <strong>{{t "adminEmailChanged.body.newEmail" lng=language}}:</strong> {{newEmail}} +</p> - <!-- 도움 요청 문구 --> - <p> - {{t "adminEmailChanged.supportMsg" lng=language}} - </p> +<p> + <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin-top: 16px;"> + {{t "adminEmailChanged.loginCTA" lng=language}} + </a> +</p> - <!-- 푸터 --> - <div class="footer"> - <p> - {{t "adminEmailChanged.footerDisclaimer" lng=language}} - </p> - </div> - </div> - </body> -</html>
\ No newline at end of file +<p style="font-size:16px; line-height:24px; margin-top:16px;"> + {{t "adminEmailChanged.supportMsg" lng=language}} +</p> + +{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file diff --git a/lib/mail/templates/cbe-invitation.hbs b/lib/mail/templates/cbe-invitation.hbs new file mode 100644 index 00000000..1d5e8eba --- /dev/null +++ b/lib/mail/templates/cbe-invitation.hbs @@ -0,0 +1,108 @@ +{{#> layout title="상업 입찰 평가 (CBE) 알림"}} + <p style="font-size:16px;">안녕하세요, <strong>{{contactName}}</strong>님</p> + + <p style="font-size:16px;"><strong>[RFQ {{rfqCode}}]</strong>에 대한 상업 입찰 평가(CBE)가 생성되어 알려드립니다. + 아래 세부 정보를 확인하시고 필요한 조치를 취해주시기 바랍니다.</p> + + <div class="info-box" style="background-color:#F1F5F9; border-radius:6px; padding:16px; margin-bottom:20px;"> + <h3 style="font-size:20px; color:#163CC4; margin-top:0; margin-bottom:12px;">RFQ 정보</h3> + <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">RFQ 코드:</span> {{rfqCode}}</div> + <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">프로젝트 코드:</span> {{projectCode}}</div> + <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">프로젝트명:</span> {{projectName}}</div> + {{#if dueDate}} + <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">마감일:</span> {{dueDate}}</div> + {{/if}} + </div> + + <div class="info-box" style="background-color:#F1F5F9; border-radius:6px; padding:16px; margin-bottom:20px;"> + <h3 style="font-size:20px; color:#163CC4; margin-top:0; margin-bottom:12px;">CBE 평가 세부사항</h3> + <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">협력업체:</span> {{vendorName}} ({{vendorCode}})</div> + {{#if paymentTerms}} + <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">결제 조건:</span> {{paymentTerms}}</div> + {{/if}} + {{#if incoterms}} + <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">Incoterms:</span> {{incoterms}}</div> + {{/if}} + {{#if deliverySchedule}} + <div class="info-item" style="margin-bottom:8px;"><span class="label" style="font-weight:bold; color:#4B5563;">배송 일정:</span> {{deliverySchedule}}</div> + {{/if}} + </div> + + {{#if description}} + <div class="info-box" style="background-color:#F1F5F9; border-radius:6px; padding:16px; margin-bottom:20px;"> + <h3 style="font-size:20px; color:#163CC4; margin-top:0; margin-bottom:12px;">RFQ 설명</h3> + <p style="font-size:16px; margin:0;">{{description}}</p> + </div> + {{/if}} + + {{#if notes}} + <div class="info-box" style="background-color:#F1F5F9; border-radius:6px; padding:16px; margin-bottom:20px;"> + <h3 style="font-size:20px; color:#163CC4; margin-top:0; margin-bottom:12px;">비고</h3> + <p style="font-size:16px; margin:0;">{{notes}}</p> + </div> + {{/if}} + + <p style="text-align: center; margin: 25px 0;"> + <a href="{{loginUrl}}/rfq/{{rfqId}}/cbe/{{cbeId}}" class="button" style="display:inline-block; background-color:#163CC4; color:#ffffff; padding:10px 20px; text-decoration:none; border-radius:4px; font-weight:bold;"> + CBE 평가 확인하기 + </a> + </p> + + <p style="font-size:16px;">이 이메일에 첨부된 파일을 확인하시거나, 시스템에 로그인하여 자세한 정보를 확인해 주세요. + 추가 문의사항이 있으시면 구매담당자에게 연락해 주시기 바랍니다.</p> + + <p style="font-size:16px;">감사합니다.<br />eVCP 팀</p> +{{/layout}} +{{!-- {{#> layout title="상업 입찰 평가 (CBE) 알림"}} + <p>안녕하세요, <strong>{{contactName}}</strong>님</p> + + <p><strong>[RFQ {{rfqCode}}]</strong>에 대한 상업 입찰 평가(CBE)가 생성되어 알려드립니다. + 아래 세부 정보를 확인하시고 필요한 조치를 취해주시기 바랍니다.</p> + + <div class="info-box"> + <h3>RFQ 정보</h3> + <div class="info-item"><span class="label">RFQ 코드:</span> {{rfqCode}}</div> + <div class="info-item"><span class="label">프로젝트 코드:</span> {{projectCode}}</div> + <div class="info-item"><span class="label">프로젝트명:</span> {{projectName}}</div> + {{#if dueDate}} + <div class="info-item"><span class="label">마감일:</span> {{dueDate}}</div> + {{/if}} + </div> + + <div class="info-box"> + <h3>CBE 평가 세부사항</h3> + <div class="info-item"><span class="label">협력업체:</span> {{vendorName}} ({{vendorCode}})</div> + {{#if paymentTerms}} + <div class="info-item"><span class="label">결제 조건:</span> {{paymentTerms}}</div> + {{/if}} + {{#if incoterms}} + <div class="info-item"><span class="label">Incoterms:</span> {{incoterms}}</div> + {{/if}} + {{#if deliverySchedule}} + <div class="info-item"><span class="label">배송 일정:</span> {{deliverySchedule}}</div> + {{/if}} + </div> + + {{#if description}} + <div class="info-box"> + <h3>RFQ 설명</h3> + <p>{{description}}</p> + </div> + {{/if}} + + {{#if notes}} + <div class="info-box"> + <h3>비고</h3> + <p>{{notes}}</p> + </div> + {{/if}} + + <div class="button-container"> + <a href="{{loginUrl}}/rfq/{{rfqId}}/cbe/{{cbeId}}" class="button"> + CBE 평가 확인하기 + </a> + </div> + + <p>이 이메일에 첨부된 파일을 확인하시거나, 시스템에 로그인하여 자세한 정보를 확인해 주세요. + 추가 문의사항이 있으시면 구매담당자에게 연락해 주시기 바랍니다.</p> +{{/layout}} --}}
\ No newline at end of file diff --git a/lib/mail/templates/contract-sign-request.hbs b/lib/mail/templates/contract-sign-request.hbs new file mode 100644 index 00000000..410fdf6a --- /dev/null +++ b/lib/mail/templates/contract-sign-request.hbs @@ -0,0 +1,116 @@ +<!DOCTYPE html> +<html lang="{{language}}"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>기본계약서 서명 요청</title> + <style> + body { + font-family: 'Malgun Gothic', 'Segoe UI', sans-serif; + line-height: 1.6; + color: #333; + background-color: #f9f9f9; + margin: 0; + padding: 0; + } + .container { + max-width: 600px; + margin: 0 auto; + padding: 20px; + background-color: #ffffff; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + } + .header { + text-align: center; + padding-bottom: 20px; + border-bottom: 1px solid #eee; + } + .logo { + max-height: 60px; + margin-bottom: 10px; + } + .content { + padding: 30px 20px; + } + .footer { + margin-top: 30px; + padding-top: 20px; + border-top: 1px solid #eee; + font-size: 12px; + color: #666; + text-align: center; + } + .button { + display: inline-block; + background-color: #4F46E5; + color: white; + text-decoration: none; + padding: 12px 24px; + border-radius: 4px; + margin: 20px 0; + font-weight: bold; + } + .button:hover { + background-color: #4338CA; + } + .info-box { + background-color: #f3f4f6; + border-radius: 4px; + padding: 15px; + margin: 20px 0; + } + @media only screen and (max-width: 600px) { + .container { + width: 100%; + border-radius: 0; + } + } + </style> +</head> +<body> + <div class="container"> + <div class="header"> + <img src="{{logoUrl}}" alt="회사 로고" class="logo"> + <h1>기본계약서 서명 요청</h1> + </div> + + <div class="content"> + <p>안녕하세요, <strong>{{vendorName}}</strong>님.</p> + + <p>귀사에 기본계약서 서명을 요청드립니다.</p> + + <div class="info-box"> + <p><strong>계약서 정보:</strong></p> + <p>계약서 종류: {{templateName}}</p> + <p>계약 번호: {{contractId}}</p> + </div> + + <p>아래 버튼을 클릭하여 계약서를 확인하고 서명해 주시기 바랍니다.</p> + + <div style="text-align: center;"> + <a href="{{loginUrl}}" class="button">계약서 서명하기</a> + </div> + + <p>본 링크는 30일간 유효하며, 이후에는 새로운 서명 요청이 필요합니다.</p> + + <p>서명 과정에서 문의사항이 있으시면 담당자에게 연락해 주시기 바랍니다.</p> + + <p>감사합니다.</p> + + <div style="margin-top: 30px;"> + <p><strong>담당자 연락처:</strong><br> + 이메일: contact@company.com<br> + 전화: 02-123-4567</p> + </div> + </div> + + <div class="footer"> + <p>본 메일은 발신 전용으로, 회신하실 경우 확인이 어려울 수 있습니다.</p> + <p>© {{currentYear}} 주식회사 회사명. 모든 권리 보유.</p> + <p>이 이메일에 포함된 정보는 기밀 정보이며, 특정 수신자만을 위한 것입니다. + 만약 귀하가 의도된 수신자가 아닌 경우, 본 이메일의 사용, 배포 또는 복사는 엄격히 금지됩니다.</p> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/investigation-request.hbs b/lib/mail/templates/investigation-request.hbs new file mode 100644 index 00000000..a69091a5 --- /dev/null +++ b/lib/mail/templates/investigation-request.hbs @@ -0,0 +1,31 @@ +{{> header logoUrl=logoUrl }} + +<h2 style="font-size:28px; margin-bottom:16px;"> 협력업체 실사 요청</h2> + +<p style="font-size:16px;">안녕하세요,</p> + +<p style="font-size:16px;">협력업체 실사 요청이 접수되었습니다.</p> + +<div style="background-color:#F1F5F9; padding:16px; border-radius:4px; margin:16px 0;"> + <p style="font-size:16px; margin:0 0 8px 0;"><strong>협력업체 ID:</strong></p> + <ul style="margin:0; padding-left:20px;"> + {{#each vendorIds}} + <li style="font-size:16px; margin-bottom:4px;">{{this}}</li> + {{/each}} + </ul> + + {{#if notes}} + <p style="font-size:16px; margin:16px 0 0 0;"><strong>메모:</strong></p> + <p style="font-size:16px; margin:8px 0 0 0;">{{notes}}</p> + {{/if}} +</div> + +<p style="text-align: center; margin: 25px 0;"> + <a href="{{portalUrl}}" target="_blank" style="display:inline-block; background-color:#163CC4; color:#ffffff; padding:10px 20px; text-decoration:none; border-radius:4px;">벤더 포털 바로가기</a> +</p> + +<p style="font-size:16px;">문의사항이 있으시면 시스템 관리자에게 연락해 주세요.</p> + +<p style="font-size:16px;">감사합니다.<br />eVCP 팀</p> + +{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file diff --git a/lib/mail/templates/otp.hbs b/lib/mail/templates/otp.hbs index adeda416..48df33f9 100644 --- a/lib/mail/templates/otp.hbs +++ b/lib/mail/templates/otp.hbs @@ -1,77 +1,27 @@ -<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="UTF-8" /> - <meta name="viewport" content="width=device-width, initial-scale=1.0"/> - <title>{{subject}}</title> - <style> - body { - font-family: Arial, sans-serif; - background: #f9fafb; - color: #111827; - padding: 20px; - } - .container { - max-width: 480px; - margin: 0 auto; - background: #ffffff; - border: 1px solid #e5e7eb; - border-radius: 6px; - padding: 24px; - } - h1 { - font-size: 20px; - margin-bottom: 8px; - color: #111827; - } - p { - line-height: 1.5; - margin-bottom: 16px; - } - .code { - display: inline-block; - font-size: 24px; - font-weight: bold; - letter-spacing: 2px; - margin: 12px 0; - background: #f3f4f6; - padding: 8px 16px; - border-radius: 4px; - } - a { - color: #3b82f6; - text-decoration: none; - } - .footer { - font-size: 12px; - color: #6b7280; - margin-top: 24px; - } - </style> - </head> - <body> - <div class="container"> - <h1>{{t "verifyYourEmailTitle"}}</h1> - <p>{{t "greeting"}}, {{name}}</p> +{{> header logoUrl=logoUrl }} - <p> - {{t "receivedSignInAttempt" location=location}} - </p> +<h1 style="font-size:28px; margin-bottom:20px; color:#111827;">{{t "verifyYourEmailTitle"}}</h1> - <p> - {{t "enterCodeInstruction"}} - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;">{{t "greeting"}}, {{name}}</p> - <p class="code">{{otp}}</p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{t "receivedSignInAttempt" location=location}} +</p> - <p> - <a href="{{verificationUrl}}">{{verificationUrl}}</a> - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{t "enterCodeInstruction"}} +</p> +<p style="font-size:24px; font-weight:bold; letter-spacing:2px; background-color:#f3f4f6; padding:8px 16px; border-radius:4px; display:inline-block; margin:12px 0;"> + {{otp}} +</p> - <div class="footer"> - {{t "securityWarning"}} - </div> - </div> - </body> -</html>
\ No newline at end of file +<p style="font-size:16px; line-height:24px; margin-bottom:16px;"> + <a href="{{verificationUrl}}" style="color:#0284C7; text-decoration:none;">{{verificationUrl}}</a> +</p> + +<p style="font-size:16px; color:#6b7280; margin-top:16px;"> + {{t "securityWarning"}} +</p> + +{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file diff --git a/lib/mail/templates/pq-submitted-admin.hbs b/lib/mail/templates/pq-submitted-admin.hbs new file mode 100644 index 00000000..0db3d6e4 --- /dev/null +++ b/lib/mail/templates/pq-submitted-admin.hbs @@ -0,0 +1,84 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>PQ Submission Notification</title> + <style> + body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; + padding: 20px; + max-width: 600px; + margin: 0 auto; + } + .header { + background-color: #0070f3; + color: white; + padding: 15px; + text-align: center; + margin-bottom: 20px; + border-radius: 5px; + } + .content { + background-color: #f9f9f9; + padding: 20px; + border-radius: 5px; + margin-bottom: 20px; + } + .details { + margin: 15px 0; + } + .details p { + margin: 5px 0; + } + .button { + display: inline-block; + background-color: #0070f3; + color: white; + text-decoration: none; + padding: 10px 20px; + border-radius: 5px; + margin: 20px 0; + text-align: center; + } + .footer { + text-align: center; + font-size: 12px; + color: #777; + margin-top: 30px; + } + </style> +</head> +<body> + <div class="header"> + <h1>PQ Submission Notification</h1> + </div> + + <div class="content"> + <h2>New PQ Submission Received</h2> + + <p>A new {{#if isProjectPQ}}project-specific{{else}}general{{/if}} PQ has been submitted and is ready for your review.</p> + + <div class="details"> + <p><strong>Vendor Name:</strong> {{vendorName}}</p> + <p><strong>Vendor ID:</strong> {{vendorId}}</p> + {{#if isProjectPQ}} + <p><strong>Project Name:</strong> {{projectName}}</p> + <p><strong>Project ID:</strong> {{projectId}}</p> + {{/if}} + <p><strong>Submission Date:</strong> {{submittedDate}}</p> + </div> + + <p>Please review this submission at your earliest convenience.</p> + + <a href="{{adminUrl}}" class="button">Review PQ Submission</a> + </div> + + <div class="footer"> + <p>This is an automated notification from the eVCP system. Please do not reply to this email.</p> + <p>© {{currentYear}} eVCP - Vendor Compliance Portal</p> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/pq-submitted-vendor.hbs b/lib/mail/templates/pq-submitted-vendor.hbs new file mode 100644 index 00000000..9cf8e133 --- /dev/null +++ b/lib/mail/templates/pq-submitted-vendor.hbs @@ -0,0 +1,93 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>PQ Submission Confirmation</title> + <style> + body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; + padding: 20px; + max-width: 600px; + margin: 0 auto; + } + .header { + background-color: #0070f3; + color: white; + padding: 15px; + text-align: center; + margin-bottom: 20px; + border-radius: 5px; + } + .content { + background-color: #f9f9f9; + padding: 20px; + border-radius: 5px; + margin-bottom: 20px; + } + .details { + margin: 15px 0; + } + .details p { + margin: 5px 0; + } + .button { + display: inline-block; + background-color: #0070f3; + color: white; + text-decoration: none; + padding: 10px 20px; + border-radius: 5px; + margin: 20px 0; + text-align: center; + } + .footer { + text-align: center; + font-size: 12px; + color: #777; + margin-top: 30px; + } + .success-message { + padding: 15px; + background-color: #dff0d8; + border-left: 4px solid #5cb85c; + margin-bottom: 20px; + } + </style> +</head> +<body> + <div class="header"> + <h1>PQ Submission Confirmation</h1> + </div> + + <div class="content"> + <div class="success-message"> + <p>Thank you! Your {{#if isProjectPQ}}project-specific{{else}}general{{/if}} PQ has been successfully submitted.</p> + </div> + + <h2>Submission Details</h2> + + <div class="details"> + <p><strong>Vendor Name:</strong> {{vendorName}}</p> + {{#if isProjectPQ}} + <p><strong>Project Name:</strong> {{projectName}}</p> + {{/if}} + <p><strong>Submission Date:</strong> {{submittedDate}}</p> + </div> + + <p>Our team will review your submission and contact you if any additional information is needed.</p> + + <p>You can access your dashboard to track the status of your submissions and manage your vendor profile.</p> + + <a href="{{portalUrl}}" class="button">Go to Vendor Dashboard</a> + </div> + + <div class="footer"> + <p>This is an automated confirmation from the eVCP system. Please do not reply to this email.</p> + <p>If you have any questions, please contact your procurement representative.</p> + <p>© {{currentYear}} eVCP - Vendor Compliance Portal</p> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/pq.hbs b/lib/mail/templates/pq.hbs new file mode 100644 index 00000000..78fb6fcd --- /dev/null +++ b/lib/mail/templates/pq.hbs @@ -0,0 +1,86 @@ +<!DOCTYPE html> +<html lang="{{language}}"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>PQ Invitation</title> + <style> + body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + } + .header { + background-color: #0066cc; + color: white; + padding: 20px; + text-align: center; + border-radius: 5px 5px 0 0; + } + .content { + background-color: #f9f9f9; + padding: 20px; + border-left: 1px solid #ddd; + border-right: 1px solid #ddd; + } + .footer { + background-color: #f1f1f1; + padding: 15px; + text-align: center; + font-size: 14px; + border-radius: 0 0 5px 5px; + border: 1px solid #ddd; + } + .button { + display: inline-block; + background-color: #0066cc; + color: white; + padding: 12px 25px; + text-decoration: none; + border-radius: 5px; + margin: 20px 0; + font-weight: bold; + } + </style> +</head> +<body> + <div class="header"> + <h1>eVCP Pre-Qualification Invitation</h1> + </div> + + <div class="content"> + <p>Dear {{vendorName}},</p> + + <p>You have been invited to submit your Pre-Qualification (PQ) information for our vendor database. Completing this process will allow us to consider your company for future projects and procurement opportunities.</p> + + <p>To submit your PQ information:</p> + <ol> + <li>Click on the button below to access our vendor portal</li> + <li>Log in to your account (or register if you haven't already)</li> + <li>Navigate to the PQ section in your dashboard</li> + <li>Complete all required information about your company, capabilities, and experience</li> + </ol> + + <center> + <a href="{{loginUrl}}" class="button">Access Vendor Portal</a> + </center> + + <p>Maintaining up-to-date PQ information in our system is essential for your company to be considered for upcoming opportunities.</p> + + <p>If you have any questions or need assistance, please contact our vendor management team.</p> + + <p>We look forward to learning more about your company and potentially working together on future projects.</p> + + <p>Best regards,<br> + The eVCP Team</p> + </div> + + <div class="footer"> + <p>This is an automated email. Please do not reply to this message.</p> + <p>© eVCP Vendor Management System</p> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/project-pq.hbs b/lib/mail/templates/project-pq.hbs new file mode 100644 index 00000000..2ecbd3a2 --- /dev/null +++ b/lib/mail/templates/project-pq.hbs @@ -0,0 +1,99 @@ +<!DOCTYPE html> +<html lang="{{language}}"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Project PQ Invitation</title> + <style> + body { + font-family: Arial, sans-serif; + line-height: 1.6; + color: #333; + max-width: 600px; + margin: 0 auto; + padding: 20px; + } + .header { + background-color: #0066cc; + color: white; + padding: 20px; + text-align: center; + border-radius: 5px 5px 0 0; + } + .content { + background-color: #f9f9f9; + padding: 20px; + border-left: 1px solid #ddd; + border-right: 1px solid #ddd; + } + .footer { + background-color: #f1f1f1; + padding: 15px; + text-align: center; + font-size: 14px; + border-radius: 0 0 5px 5px; + border: 1px solid #ddd; + } + .button { + display: inline-block; + background-color: #0066cc; + color: white; + padding: 12px 25px; + text-decoration: none; + border-radius: 5px; + margin: 20px 0; + font-weight: bold; + } + .project-info { + background-color: #e6f2ff; + padding: 15px; + border-radius: 5px; + margin: 15px 0; + } + </style> +</head> +<body> + <div class="header"> + <h1>eVCP Project PQ Invitation</h1> + </div> + + <div class="content"> + <p>Dear {{vendorName}},</p> + + <p>You have been selected to participate in the Pre-Qualification (PQ) process for the following project:</p> + + <div class="project-info"> + <p><strong>Project Code:</strong> {{projectCode}}</p> + <p><strong>Project Name:</strong> {{projectName}}</p> + </div> + + <p>This is an important step in our vendor selection process. Please complete the Project PQ questionnaire at your earliest convenience.</p> + + <p>To submit your Project PQ:</p> + <ol> + <li>Click on the button below to access our vendor portal</li> + <li>Log in to your account (or register if you haven't already)</li> + <li>Navigate to the PQ section where you'll find the Project PQ for {{projectCode}}</li> + <li>Complete all required information</li> + </ol> + + <center> + <a href="{{loginUrl}}" class="button">Access Vendor Portal</a> + </center> + + <p>Please note that completing this Project PQ is a prerequisite for being considered for this project.</p> + + <p>If you have any questions or need assistance, please contact our vendor management team.</p> + + <p>Thank you for your participation.</p> + + <p>Best regards,<br> + The eVCP Team</p> + </div> + + <div class="footer"> + <p>This is an automated email. Please do not reply to this message.</p> + <p>© eVCP Vendor Management System</p> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/mail/templates/rfq-invite.hbs b/lib/mail/templates/rfq-invite.hbs index 25bd96eb..8ec20a99 100644 --- a/lib/mail/templates/rfq-invite.hbs +++ b/lib/mail/templates/rfq-invite.hbs @@ -1,116 +1,43 @@ -<!DOCTYPE html> -<html> - <head> - <meta charset="utf-8" /> - <title>{{t "rfqInvite.title" lng=language}} #{{rfqCode}}</title> - <style> - /* 간단한 스타일 예시 */ - body { - font-family: Arial, sans-serif; - margin: 0; - padding: 16px; - background-color: #f5f5f5; - } - .container { - max-width: 600px; - margin: 0 auto; - background-color: #ffffff; - padding: 24px; - border-radius: 8px; - } - h1 { - font-size: 20px; - margin-bottom: 16px; - } - p { - font-size: 14px; - line-height: 1.6; - } - ul { - margin-left: 20px; - } - li { - font-size: 14px; - line-height: 1.6; - } - .btn { - display: inline-block; - margin-top: 16px; - padding: 12px 24px; - background-color: #1D4ED8; - color: #ffffff !important; - text-decoration: none; - border-radius: 4px; - } - .footer { - margin-top: 24px; - font-size: 12px; - color: #888888; - } - </style> - </head> - <body> - <div class="container"> - <!-- 상단 로고/타이틀 영역 --> - <div style="text-align: center;"> - <!-- 필요 시 로고 이미지 --> - <!-- <img src="https://your-logo-url.com/logo.png" alt="EVCP" width="120" /> --> - </div> +{{> header logoUrl=logoUrl }} - <!-- 메인 타이틀: RFQ 초대 --> - <h1> - {{t "rfqInvite.heading" lng=language}} - #{{rfqCode}} - </h1> +<h1 style="font-size:28px; line-height:40px; margin-bottom:16px;"> + {{t "rfqInvite.heading" lng=language}} #{{rfqCode}} +</h1> - <!-- 벤더에게 인사말 --> - <p> - {{t "rfqInvite.greeting" lng=language}}, <strong>Vendor #{{vendorId}}</strong>. - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{t "rfqInvite.greeting" lng=language}}, <strong>Vendor #{{vendorId}}</strong>. +</p> - <!-- 프로젝트/RFQ 정보 --> - <p> - {{t "rfqInvite.bodyIntro" lng=language}} - <br /> - <strong>{{t "rfqInvite.projectName" lng=language}}:</strong> {{projectName}}<br /> - <strong>{{t "rfqInvite.projectCode" lng=language}}:</strong> {{projectCode}}<br /> - <strong>{{t "rfqInvite.dueDate" lng=language}}:</strong> {{dueDate}}<br /> - <strong>{{t "rfqInvite.description" lng=language}}:</strong> {{description}} - </p> +<p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{t "rfqInvite.bodyIntro" lng=language}}<br/> + <strong>{{t "rfqInvite.projectName" lng=language}}:</strong> {{projectName}}<br /> + <strong>{{t "rfqInvite.projectCode" lng=language}}:</strong> {{projectCode}}<br /> + <strong>{{t "rfqInvite.dueDate" lng=language}}:</strong> {{dueDate}}<br /> + <strong>{{t "rfqInvite.description" lng=language}}:</strong> {{description}} +</p> - <!-- 아이템 목록 --> - <p> - {{t "rfqInvite.itemListTitle" lng=language}} - </p> - <ul> - {{#each items}} - <li> - <strong>{{this.itemCode}}</strong> - ({{this.quantity}} {{this.uom}}) - - {{this.description}} - </li> - {{/each}} - </ul> +<p style="font-size:16px; line-height:32px; margin-bottom:8px;"> + {{t "rfqInvite.itemListTitle" lng=language}} +</p> - <!-- 로그인/접속 안내 --> - <p> - {{t "rfqInvite.moreDetail" lng=language}} - </p> - <a class="btn" href="{{loginUrl}}" target="_blank"> - {{t "rfqInvite.viewButton" lng=language}} - </a> +<ul style="margin-left:4px; font-size:16px; line-height:32px;"> + {{#each items}} + <li><strong>{{this.itemCode}}</strong> ({{this.quantity}} {{this.uom}}) - {{this.description}}</li> + {{/each}} +</ul> - <!-- 기타 안내 문구 --> - <p> - {{t "rfqInvite.supportMsg" lng=language}} - </p> +<p style="font-size:14px; line-height:32px; margin-top:16px;"> + {{t "rfqInvite.moreDetail" lng=language}} +</p> - <!-- 푸터 --> - <div class="footer"> - <p> - {{t "rfqInvite.footerDisclaimer" lng=language}} - </p> - </div> - </div> - </body> -</html>
\ No newline at end of file +<p> + <a href="{{loginUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px;"> + {{t "rfqInvite.viewButton" lng=language}} + </a> +</p> + +<p style="font-size:16px; line-height:24px; margin-top:16px;"> + {{t "rfqInvite.supportMsg" lng=language}} +</p> + +{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file diff --git a/lib/mail/templates/vendor-active.hbs b/lib/mail/templates/vendor-active.hbs index 6458e2fb..a2643f94 100644 --- a/lib/mail/templates/vendor-active.hbs +++ b/lib/mail/templates/vendor-active.hbs @@ -1,51 +1,25 @@ -<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8"> - <title>벤더 등록이 완료되었습니다</title> - <style> - body { font-family: 'Malgun Gothic', sans-serif; line-height: 1.6; } - .container { max-width: 600px; margin: 0 auto; padding: 20px; } - .header { background-color: #f5f5f5; padding: 10px; text-align: center; } - .content { padding: 20px 0; } - .vendor-code { font-size: 18px; font-weight: bold; background-color: #f0f0f0; - padding: 10px; margin: 15px 0; text-align: center; } - .button { display: inline-block; background-color: #28a745; color: white; - padding: 10px 20px; text-decoration: none; border-radius: 4px; } - .footer { margin-top: 20px; font-size: 12px; color: #777; } - </style> -</head> -<body> - <div class="container"> - <div class="header"> - <h2>벤더 등록이 완료되었습니다</h2> - </div> - - <div class="content"> - <p>{{vendorName}} 귀하,</p> - - <p>축하합니다! 귀사의 벤더 등록이 완료되었으며 벤더 정보가 당사 시스템에 성공적으로 등록되었습니다.</p> - - <p>귀사의 벤더 코드는 다음과 같습니다:</p> - <div class="vendor-code">{{vendorCode}}</div> - - <p>향후 모든 의사소통 및 거래 시 이 벤더 코드를 사용해 주십시오. 이제 벤더 포털에 접속하여 계정 관리, 발주서 확인 및 인보이스 제출을 할 수 있습니다.</p> - - <p style="text-align: center; margin: 25px 0;"> - <a href="{{portalUrl}}" class="button">벤더 포털 접속</a> - </p> - - <p>벤더 계정에 관한 질문이나 도움이 필요하시면 당사 벤더 관리팀에 문의해 주십시오.</p> - - <p>파트너십에 감사드립니다.</p> - - <p>감사합니다.<br> - eVCP 팀</p> - </div> - - <div class="footer"> - <p>이 메시지는 자동으로 발송되었습니다. 이 이메일에 회신하지 마십시오.</p> - </div> - </div> -</body> -</html>
\ No newline at end of file +{{> header logoUrl=logoUrl }} + +<h2 style="font-size:28px; margin-bottom:16px;">협력업체 등록이 완료되었습니다</h2> + +<p style="font-size:16px;">{{vendorName}} 귀하,</p> + +<p style="font-size:16px;">축하합니다! 귀사의 협력업체 등록이 완료되었으며 협력업체 정보가 당사 시스템에 성공적으로 등록되었습니다.</p> + +<p style="font-size:16px;">귀사의 협력업체 코드는 다음과 같습니다:</p> + +<div style="font-size:24px; font-weight:bold; letter-spacing:2px; background-color:#F1F5F9; padding:8px 16px; border-radius:4px; display:inline-block; margin:12px 0;"> + {{vendorCode}} +</div> + +<p style="font-size:1px;">이 코드를 사용하여 포털에 접속하고 계정을 관리할 수 있습니다.</p> + +<p style="text-align: center; margin: 25px 0;"> + <a href="{{portalUrl}}" target="_blank" style="display:inline-block; background-color:#163CC4; color:#ffffff; padding:10px 20px; text-decoration:none; border-radius:4px;">협력업체 포털 접속</a> +</p> + +<p style="font-size:16px;">문의사항이 있으시면 협력업체 관리팀에 연락해 주세요.</p> + +<p style="font-size:16px;">감사합니다.<br />eVCP 팀</p> + +{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file diff --git a/lib/mail/templates/vendor-additional-info.hbs b/lib/mail/templates/vendor-additional-info.hbs index 9d93bb7b..17d9b130 100644 --- a/lib/mail/templates/vendor-additional-info.hbs +++ b/lib/mail/templates/vendor-additional-info.hbs @@ -1,76 +1,19 @@ -<!DOCTYPE html> -<html> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>{{t "email.additionalInfo.title"}}</title> - <style> - body { - font-family: Arial, sans-serif; - line-height: 1.6; - color: #333; - max-width: 600px; - margin: 0 auto; - padding: 20px; - } - .header { - background-color: #0056b3; - color: white; - padding: 20px; - text-align: center; - border-radius: 5px 5px 0 0; - } - .content { - padding: 20px; - border: 1px solid #ddd; - border-top: none; - border-radius: 0 0 5px 5px; - } - .button { - background-color: #0056b3; - color: white; - padding: 12px 20px; - text-decoration: none; - border-radius: 5px; - display: inline-block; - margin-top: 15px; - font-weight: bold; - } - .footer { - margin-top: 30px; - text-align: center; - font-size: 0.8em; - color: #777; - } - </style> -</head> -<body> - <div class="header"> - <h1>{{t "email.additionalInfo.header"}}</h1> - </div> +{{#> layout title=(t "email.additionalInfo.title")}} + <p>{{t "email.additionalInfo.greeting" vendorName=vendorName}}</p> - <div class="content"> - <p>{{t "email.additionalInfo.greeting" vendorName=vendorName}}</p> - - <p>{{t "email.additionalInfo.messageP1"}}</p> - - <p>{{t "email.additionalInfo.messageP2"}}</p> - - <p>{{t "email.additionalInfo.messageP3"}}</p> - - <div style="text-align: center;"> - <a href="{{vendorInfoUrl}}" class="button">{{t "email.additionalInfo.buttonText"}}</a> - </div> - - <p>{{t "email.additionalInfo.messageP4"}}</p> - - <p>{{t "email.additionalInfo.closing"}}</p> - - <p>EVCP Team</p> - </div> + <p>{{t "email.additionalInfo.messageP1"}}</p> + + <p>{{t "email.additionalInfo.messageP2"}}</p> + + <p>{{t "email.additionalInfo.messageP3"}}</p> - <div class="footer"> - <p>© {{currentYear}} EVCP. {{t "email.additionalInfo.footerText"}}</p> + <div class="button-container"> + <a href="{{vendorInfoUrl}}" class="button">{{t "email.additionalInfo.buttonText"}}</a> </div> -</body> -</html>
\ No newline at end of file + + <p>{{t "email.additionalInfo.messageP4"}}</p> + + <p>{{t "email.additionalInfo.closing"}}</p> + + <p>EVCP Team</p> +{{/layout}}
\ No newline at end of file diff --git a/lib/mail/templates/vendor-invitation.hbs b/lib/mail/templates/vendor-invitation.hbs index d85067f4..9b68c10c 100644 --- a/lib/mail/templates/vendor-invitation.hbs +++ b/lib/mail/templates/vendor-invitation.hbs @@ -1,86 +1,20 @@ -<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>Vendor Registration Invitation</title> - <style> - body { - font-family: Arial, sans-serif; - line-height: 1.6; - color: #333333; - margin: 0; - padding: 0; - } - .container { - max-width: 600px; - margin: 0 auto; - padding: 20px; - } - .header { - background-color: #2563EB; - padding: 20px; - text-align: center; - color: white; - } - .content { - padding: 20px; - background-color: #ffffff; - } - .footer { - padding: 20px; - text-align: center; - font-size: 12px; - color: #666666; - background-color: #f5f5f5; - } - .button { - display: inline-block; - background-color: #2563EB; - color: white; - padding: 12px 24px; - text-decoration: none; - border-radius: 4px; - margin: 20px 0; - font-weight: bold; - } - .highlight { - background-color: #f8f9fa; - padding: 15px; - border-left: 4px solid #2563EB; - margin: 20px 0; - } - </style> -</head> -<body> - <div class="container"> - <div class="header"> - <h1>{{t "email.vendor.invitation.title"}}</h1> - </div> - <div class="content"> - <p>{{t "email.vendor.invitation.greeting"}} {{companyName}},</p> - - <p>{{t "email.vendor.invitation.message"}}</p> - - <div class="highlight"> - <p>{{t "email.vendor.invitation.details"}}</p> - </div> - - <div style="text-align: center;"> - <a href="{{registrationLink}}" class="button">{{t "email.vendor.invitation.register_now"}}</a> - </div> - - <p>{{t "email.vendor.invitation.expire_notice"}}</p> - - <p>{{t "email.vendor.invitation.footer"}}</p> - - <p>{{t "email.vendor.invitation.signature"}}<br> - EVCP {{t "email.vendor.invitation.team"}}</p> - </div> - <div class="footer"> - <p>© {{currentYear}} EVCP. {{t "email.vendor.invitation.copyright"}}</p> - <p>{{t "email.vendor.invitation.no_reply"}}</p> - </div> +{{#> layout title=(t "email.vendor.invitation.title")}} + <p>{{t "email.vendor.invitation.greeting"}} {{companyName}},</p> + + <p>{{t "email.vendor.invitation.message"}}</p> + + <div class="info-box"> + <p>{{t "email.vendor.invitation.details"}}</p> </div> -</body> -</html>
\ No newline at end of file + + <div class="button-container"> + <a href="{{registrationLink}}" class="button">{{t "email.vendor.invitation.register_now"}}</a> + </div> + + <p>{{t "email.vendor.invitation.expire_notice"}}</p> + + <p>{{t "email.vendor.invitation.footer"}}</p> + + <p>{{t "email.vendor.invitation.signature"}}<br> + EVCP {{t "email.vendor.invitation.team"}}</p> +{{/layout}}
\ No newline at end of file diff --git a/lib/mail/templates/vendor-pq-comment.hbs b/lib/mail/templates/vendor-pq-comment.hbs index b60deedc..3606bcdb 100644 --- a/lib/mail/templates/vendor-pq-comment.hbs +++ b/lib/mail/templates/vendor-pq-comment.hbs @@ -1,128 +1,41 @@ -<!DOCTYPE html> -<html> -<head> - <meta charset="utf-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>PQ Review Comments</title> - <style> - body { - font-family: Arial, sans-serif; - line-height: 1.6; - color: #333; - margin: 0; - padding: 0; - } - .container { - max-width: 600px; - margin: 0 auto; - padding: 20px; - } - .header { - text-align: center; - padding: 20px 0; - border-bottom: 1px solid #eee; - } - .content { - padding: 20px 0; - } - .footer { - text-align: center; - padding: 20px 0; - font-size: 12px; - color: #999; - border-top: 1px solid #eee; - } - .btn { - display: inline-block; - padding: 10px 20px; - font-size: 16px; - color: #fff; - background-color: #0071bc; - text-decoration: none; - border-radius: 4px; - margin: 20px 0; - } - .comment-section { - margin: 20px 0; - padding: 15px; - background-color: #f9f9f9; - border-left: 4px solid #0071bc; - } - .comment-item { - margin-bottom: 15px; - padding-bottom: 15px; - border-bottom: 1px solid #eee; - } - .comment-item:last-child { - border-bottom: none; - } - .comment-code { - font-weight: bold; - color: #0071bc; - display: inline-block; - min-width: 60px; - } - .comment-title { - font-weight: bold; - color: #333; - } - .important { - color: #d14; - font-weight: bold; - } - </style> -</head> -<body> - <div class="container"> - <div class="header"> - <h1>PQ Review Comments</h1> - </div> - - <div class="content"> - <p>Dear {{name}} ({{vendorCode}}),</p> - - <p>Thank you for submitting your PQ information. Our review team has completed the initial review and has requested some changes or additional information.</p> - - <p><span class="important">Action Required:</span> Please log in to your account and update your PQ submission based on the comments below.</p> - - {{#if hasGeneralComment}} - <div class="comment-section"> - <h3>General Comments:</h3> - <p>{{generalComment}}</p> - </div> - {{/if}} - - <div class="comment-section"> - <h3>Specific Item Comments ({{commentCount}}):</h3> - {{#each comments}} - <div class="comment-item"> - <div> - <span class="comment-code">{{code}}</span> - <span class="comment-title">{{checkPoint}}</span> - </div> - <p>{{text}}</p> - </div> - {{/each}} - </div> - - <p>Please review these comments and make the necessary updates to your PQ submission. Once you have made the requested changes, you can resubmit your PQ for further review.</p> - - <div style="text-align: center;"> - <a href="{{loginUrl}}" class="btn">Log in to update your PQ</a> - </div> - - <p>If you have any questions or need assistance, please contact our support team.</p> - - <p>Thank you for your cooperation.</p> - - <p>Best regards,<br> - PQ Review Team</p> - </div> - - <div class="footer"> - <p>This is an automated email. Please do not reply to this message.</p> - <p>© {{currentYear}} Your Company Name. All rights reserved.</p> +{{> header logoUrl=logoUrl }} + +<h1 style="text-align:center; font-size:28px; margin-bottom:20px;">PQ Review Comments</h1> + +<p style="font-size:16px;">Dear {{name}} ({{vendorCode}}),</p> + +<p style="font-size:16px;">Thank you for submitting your PQ information. Our review team has completed the initial review and has requested some changes or additional information.</p> + +<p style="font-size:16px;"><span style="color:#d14; font-weight:bold;">Action Required:</span> Please log in to your account and update your PQ submission based on the comments below.</p> + +{{#if hasGeneralComment}} +<div style="margin:20px 0; padding:15px; background-color:#f9f9f9; border-left:4px solid #0071bc;"> + <h3>General Comments:</h3> + <p>{{generalComment}}</p> +</div> +{{/if}} + +<div style="margin:20px 0; padding:15px; background-color:#f9f9f9; border-left:4px solid #0071bc;"> + <h3>Specific Item Comments ({{commentCount}}):</h3> + {{#each comments}} + <div style="margin-bottom:15px; border-bottom:1px solid #eee; padding-bottom:15px;"> + <div> + <span style="font-weight:bold; color:#0071bc; display:inline-block; min-width:60px;">{{code}}</span> + <span style="font-weight:bold; color:#333;">{{checkPoint}}</span> </div> + <p>{{text}}</p> </div> -</body> -</html>
\ No newline at end of file + {{/each}} +</div> + +<p style="font-size:16px;">Please review these comments and update your PQ submission.</p> + +<div style="text-align:center; margin:20px 0;"> + <a href="{{loginUrl}}" class="btn" style="padding:10px 20px; font-size:16px; background-color:#0071bc; color:#fff; text-decoration:none; border-radius:4px;">Log in to update your PQ</a> +</div> + +<p style="font-size:16px;">If you have any questions, please contact our support team.</p> + +<p style="font-size:16px;">Thank you,<br />PQ Review Team</p> + +{{> footer logoUrl=logoUrl companyName=companyName year=currentYear }}
\ No newline at end of file diff --git a/lib/mail/templates/vendor-pq-status.hbs b/lib/mail/templates/vendor-pq-status.hbs index 541a6137..4a3fece5 100644 --- a/lib/mail/templates/vendor-pq-status.hbs +++ b/lib/mail/templates/vendor-pq-status.hbs @@ -1,48 +1,23 @@ -<!-- file: templates/vendor-pq-status.hbs --> +{{> header logoUrl=logoUrl }} -<html> - <body style="font-family: sans-serif; margin: 0; padding: 0;"> - <table width="100%" cellspacing="0" cellpadding="20" style="background-color: #f7f7f7;"> - <tr> - <td> - <table width="600" cellspacing="0" cellpadding="20" style="background-color: #ffffff; margin: 0 auto;"> - <tr> - <td style="text-align: center;"> - <h1 style="margin-bottom: 0.5rem;">Vendor PQ Status Update</h1> - </td> - </tr> - <tr> - <td> - <p>Hello {{name}},</p> - <p> - Your vendor status has been updated to - <strong>{{status}}</strong>. - </p> - <p> - You can log in to see details and take further action: - <br /> - <a href="{{loginUrl}}" style="color: #007bff; text-decoration: underline;"> - Go to Portal - </a> - </p> - <p> - If you have any questions, feel free to contact us. - </p> - <p>Thank you,<br/> - The PQ Team - </p> - </td> - </tr> - <tr> - <td style="text-align: center; border-top: 1px solid #eee;"> - <small style="color: #999;"> - © 2023 MyCompany - </small> - </td> - </tr> - </table> - </td> - </tr> - </table> - </body> -</html>
\ No newline at end of file +<h1 style="text-align:center; font-size:28px; margin-bottom:20px;">Vendor PQ Status Update</h1> + +<p style="font-size:16px;">Hello {{name}},</p> + +<p style="font-size:16px;"> + Your vendor status has been updated to <strong>{{status}}</strong>. +</p> + +<p style="font-size:16px;"> + You can log in to see details and take further action: + <br /> + <a href="{{loginUrl}}" target="_blank" style="color:#163CC4; text-decoration:underline;"> + Go to Portal + </a> +</p> + +<p style="font-size:16px;">If you have any questions, feel free to contact us.</p> + +<p style="font-size:16px;">Thank you,<br/>The PQ Team</p> + +{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file diff --git a/lib/mail/templates/vendor-project-pq-status.hbs b/lib/mail/templates/vendor-project-pq-status.hbs new file mode 100644 index 00000000..c051ce02 --- /dev/null +++ b/lib/mail/templates/vendor-project-pq-status.hbs @@ -0,0 +1,42 @@ +{{> header logoUrl=logoUrl }} + +<h1 style="text-align:center; font-size:28px; margin-bottom:20px;">Vendor Project PQ Status Update</h1> + +<p style="font-size:16px;">Hello {{name}},</p> + +<p style="font-size:16px;"> + Your vendor status for <strong>{{projectName}}</strong> has been updated to <strong>{{status}}</strong>. +</p> + +{{#if hasRejectionReason}} +<p style="font-size:16px; padding:15px; background-color:#f8f8f8; border-left:4px solid #e74c3c;"> + <strong>Reason for rejection:</strong><br/> + {{rejectionReason}} +</p> +{{/if}} + +{{#if approvalDate}} +<p style="font-size:16px;"> + <strong>Approval Date:</strong> {{approvalDate}} +</p> +{{/if}} + +{{#if rejectionDate}} +<p style="font-size:16px;"> + <strong>Rejection Date:</strong> {{rejectionDate}} +</p> +{{/if}} + +<p style="font-size:16px;"> + You can log in to see details and take further action: + <br /> + <a href="{{loginUrl}}" target="_blank" style="color:#163CC4; text-decoration:underline;"> + Go to Portal + </a> +</p> + +<p style="font-size:16px;">If you have any questions, feel free to contact us.</p> + +<p style="font-size:16px;">Thank you,<br/>The PQ Team</p> + +{{> footer logoUrl=logoUrl companyName=companyName year=year }}
\ No newline at end of file diff --git a/lib/poa/table/poa-table.tsx b/lib/poa/table/poa-table.tsx index a5cad02a..72b84d72 100644 --- a/lib/poa/table/poa-table.tsx +++ b/lib/poa/table/poa-table.tsx @@ -79,7 +79,7 @@ export function ChangeOrderListsTable({ promises }: ItemsTableProps) { }, { id: "vendorId", - label: "벤더 ID", + label: "협력업체 ID", type: "number", }, { diff --git a/lib/poa/validations.ts b/lib/poa/validations.ts index eae1b5ab..8fdf9bdf 100644 --- a/lib/poa/validations.ts +++ b/lib/poa/validations.ts @@ -35,7 +35,7 @@ export const searchParamsCache = createSearchParamsCache({ projectCode: parseAsString.withDefault(""), projectName: parseAsString.withDefault(""), - // 벤더 정보 + // 협력업체 정보 vendorId: parseAsString.withDefault(""), vendorName: parseAsString.withDefault(""), diff --git a/lib/pq/service.ts b/lib/pq/service.ts index ad7e60c4..6159a307 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -19,6 +19,7 @@ import { writeFile, mkdir } from 'fs/promises'; import { GetVendorsSchema } from "../vendors/validations"; import { countVendors, selectVendors } from "../vendors/repository"; import { projects } from "@/db/schema"; +import { headers } from 'next/headers'; /** * PQ 목록 조회 @@ -144,8 +145,8 @@ export async function getPQs( const createPqSchema = z.object({ code: z.string().min(1, "Code is required"), checkPoint: z.string().min(1, "Check point is required"), - description: z.string().optional(), - remarks: z.string().optional(), + description: z.string().optional().nullable(), + remarks: z.string().optional().nullable(), groupName: z.string().optional() }); @@ -409,7 +410,7 @@ export async function getPQDataByVendorId( contractInfo: pqCriteriasExtension.contractInfo, additionalRequirement: pqCriteriasExtension.additionalRequirement, - // 벤더 응답 필드 + // 협력업체 응답 필드 answer: vendorPqCriteriaAnswers.answer, answerId: vendorPqCriteriaAnswers.id, @@ -653,8 +654,8 @@ export async function savePQAnswersAction(input: SavePQInput) { /** - * PQ 제출 서버 액션 - 벤더 상태를 PQ_SUBMITTED로 업데이트 - * @param vendorId 벤더 ID + * PQ 제출 서버 액션 - 협력업체 상태를 PQ_SUBMITTED로 업데이트 + * @param vendorId 협력업체 ID */ export async function submitPQAction({ vendorId, @@ -666,6 +667,9 @@ export async function submitPQAction({ unstable_noStore(); try { + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + // 1. 모든 PQ 항목에 대한 응답이 있는지 검증 const queryConditions = [ eq(vendorPqCriteriaAnswers.vendorId, vendorId) @@ -690,7 +694,7 @@ export async function submitPQAction({ return { ok: false, error: "No PQ answers found" }; } - // 2. 벤더 정보 조회 + // 2. 협력업체 정보 조회 const vendor = await db .select({ id: vendors.id, @@ -770,7 +774,7 @@ export async function submitPQAction({ }); } } else { - // 일반 PQ인 경우 벤더 상태 검증 및 업데이트 + // 일반 PQ인 경우 협력업체 상태 검증 및 업데이트 const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"]; if (!allowedStatuses.includes(vendor.status)) { @@ -798,8 +802,8 @@ export async function submitPQAction({ : `[eVCP] PQ Submitted: ${vendor.vendorName}`; const adminUrl = projectId - ? `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/projects/${projectId}/pq` - : `${process.env.NEXT_PUBLIC_APP_URL}/admin/vendors/${vendorId}/pq`; + ? `http://${host}/evcp/pq/${vendorId}?projectId=${projectId}` + : `http://${host}/evcp/pq/${vendorId}`; await sendEmail({ to: process.env.ADMIN_EMAIL, @@ -828,9 +832,7 @@ export async function submitPQAction({ ? `[eVCP] Project PQ Submission Confirmation for ${projectName}` : "[eVCP] PQ Submission Confirmation"; - const portalUrl = projectId - ? `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/projects/${projectId}` - : `${process.env.NEXT_PUBLIC_APP_URL}/dashboard`; + const portalUrl = `${host}/dashboard`; await sendEmail({ to: vendor.email, @@ -843,7 +845,7 @@ export async function submitPQAction({ isProjectPQ: !!projectId, submittedDate: currentDate.toLocaleString(), portalUrl, - } + } }); } catch (emailError) { console.error("Failed to send vendor confirmation:", emailError); @@ -1001,10 +1003,10 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { - // 벤더 ID 모음 (중복 제거용) + // 협력업체 ID 모음 (중복 제거용) const vendorIds = new Set<number>(); - // 1-A) 일반 PQ 답변이 있는 벤더 찾기 (status와 상관없이) + // 1-A) 일반 PQ 답변이 있는 협력업체 찾기 (status와 상관없이) const generalPqVendors = await tx .select({ vendorId: vendorPqCriteriaAnswers.vendorId @@ -1025,7 +1027,7 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { generalPqVendors.forEach(v => vendorIds.add(v.vendorId)); - // 1-B) 프로젝트 PQ 답변이 있는 벤더 ID 조회 (status와 상관없이) + // 1-B) 프로젝트 PQ 답변이 있는 협력업체 ID 조회 (status와 상관없이) const projectPqVendors = await tx .select({ vendorId: vendorProjectPQs.vendorId @@ -1046,7 +1048,7 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { projectPqVendors.forEach(v => vendorIds.add(v.vendorId)); - // 중복 제거된 벤더 ID 배열 + // 중복 제거된 협력업체 ID 배열 const uniqueVendorIds = Array.from(vendorIds); // 총 개수 (중복 제거 후) @@ -1059,13 +1061,13 @@ export async function getVendorsInPQ(input: GetVendorsSchema) { // 페이징 처리 (정렬 후 limit/offset 적용) const paginatedIds = uniqueVendorIds.slice(offset, offset + input.perPage); - // 2) 페이징된 벤더 상세 정보 조회 + // 2) 페이징된 협력업체 상세 정보 조회 const vendorsData = await selectVendors(tx, { where: inArray(vendors.id, paginatedIds), orderBy: input.sort.length > 0 ? input.sort.map((item) => - item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) - ) + item.desc ? desc(vendors.vendorName) : asc(vendors.vendorName) + ) : [asc(vendors.createdAt)], }); @@ -1204,7 +1206,7 @@ function getPqStatusDisplay( return "PQ 정보 없음"; } -// 벤더 상태 텍스트 변환 +// 협력업체 상태 텍스트 변환 function getPqVendorStatusText(status: string): string { switch (status) { case "IN_PQ": return "진행중"; @@ -1249,6 +1251,9 @@ export type VendorStatus = if (!vendor) { return { ok: false, error: "Vendor not found" } } + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const loginUrl = `http://${host}/partners/pq` // 3) Send email await sendEmail({ @@ -1258,7 +1263,7 @@ export type VendorStatus = context: { name: vendor.vendorName, status: newStatus, - loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq`, // etc. + loginUrl: loginUrl, // etc. }, }) revalidateTag("vendors") @@ -1354,7 +1359,7 @@ export async function updateProjectPQStatusAction({ projectName: project.name, rejectionReason: status === "REJECTED" ? comment : undefined, hasRejectionReason: status === "REJECTED" && !!comment, - loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/projects/${projectId}/pq`, + loginUrl: `${process.env.NEXT_PUBLIC_URL}/partners/pq?projectId=${projectId}`, approvalDate: status === "APPROVED" ? currentDate.toLocaleDateString() : undefined, rejectionDate: status === "REJECTED" ? currentDate.toLocaleDateString() : undefined, }, @@ -1385,7 +1390,7 @@ interface ItemComment { /** * PQ 변경 요청 처리 서버 액션 * - * @param vendorId 벤더 ID + * @param vendorId 협력업체 ID * @param comment 항목별 코멘트 배열 (answerId, checkPoint, code, comment로 구성) * @param generalComment 전체 PQ에 대한 일반 코멘트 (선택사항) */ @@ -1394,11 +1399,15 @@ export async function requestPqChangesAction({ projectId, comment, generalComment, + reviewerName, + reviewerId }: { vendorId: number; projectId?: number; // Optional project ID for project-specific PQs comment: ItemComment[]; generalComment?: string; + reviewerName?: string; + reviewerId?: string; }) { try { // 1) 상태 업데이트 (PQ 타입에 따라 다르게 처리) @@ -1442,7 +1451,7 @@ export async function requestPqChangesAction({ .where(eq(vendors.id, vendorId)); } - // 2) 벤더 정보 가져오기 + // 2) 협력업체 정보 가져오기 const vendor = await db .select() .from(vendors) @@ -1469,8 +1478,7 @@ export async function requestPqChangesAction({ // 3) 각 항목별 코멘트 저장 const currentDate = new Date(); - const reviewerId = 1; // 관리자 ID (실제 구현에서는 세션에서 가져옵니다) - const reviewerName = "AdminUser"; // 실제 구현에서는 세션에서 가져옵니다 + // 병렬로 모든 코멘트 저장 if (comment && comment.length > 0) { @@ -1508,7 +1516,7 @@ export async function requestPqChangesAction({ // 로그인 URL - 프로젝트 PQ인 경우 다른 경로로 안내 const loginUrl = projectId - ? `${process.env.NEXT_PUBLIC_URL}/partners/projects/${projectId}/pq` + ? `${process.env.NEXT_PUBLIC_URL}/partners/pq?projectId=${projectId}` : `${process.env.NEXT_PUBLIC_URL}/partners/pq`; await sendEmail({ @@ -1676,9 +1684,22 @@ export async function getVendorPQsList(vendorId: number): Promise<VendorPQsList> export async function loadGeneralPQData(vendorId: number) { + "use server"; return getPQDataByVendorId(vendorId) } export async function loadProjectPQData(vendorId: number, projectId: number) { + "use server"; return getPQDataByVendorId(vendorId, projectId) +} + +export async function loadGeneralPQAction(vendorId: number) { + return getPQDataByVendorId(vendorId); +} + +export async function loadProjectPQAction(vendorId: number, projectId?: number): Promise<PQGroupData[]> { + if (!projectId) { + throw new Error("Project ID is required for loading project PQ data"); + } + return getPQDataByVendorId(vendorId, projectId); }
\ No newline at end of file diff --git a/lib/pq/table/import-pq-handler.tsx b/lib/pq/table/import-pq-handler.tsx index aa5e6c47..13431ba7 100644 --- a/lib/pq/table/import-pq-handler.tsx +++ b/lib/pq/table/import-pq-handler.tsx @@ -77,10 +77,10 @@ export async function processFileImport( code: row.Code?.toString().trim() ?? "", checkPoint: row["Check Point"]?.toString().trim() ?? "", groupName: row["Group Name"]?.toString().trim() ?? "", - description: row.Description?.toString() ?? null, - remarks: row.Remarks?.toString() ?? null, - contractInfo: row["Contract Info"]?.toString() ?? null, - additionalRequirement: row["Additional Requirements"]?.toString() ?? null, + description: row.Description?.toString() ?? "", + remarks: row.Remarks?.toString() ?? "", + contractInfo: row["Contract Info"]?.toString() ?? "", + additionalRequirement: row["Additional Requirements"]?.toString() ?? "", }; // 데이터 유효성 검사 @@ -109,8 +109,7 @@ export async function processFileImport( // PQ 생성 서버 액션 호출 const createResult = await createPq({ ...cleanedRow, - projectId: projectId, - isProjectSpecific: !!projectId, + projectId: projectId || 0 }); if (createResult.success) { diff --git a/lib/project-avl/repository.ts b/lib/project-avl/repository.ts new file mode 100644 index 00000000..5fef8560 --- /dev/null +++ b/lib/project-avl/repository.ts @@ -0,0 +1,49 @@ +// src/lib/projectApprovedVendors/repository.ts +import { projectApprovedVendors } from "@/db/schema"; +import { + eq, + inArray, + not, + asc, + desc, + and, + ilike, + gte, + lte, + count, + gt, +} from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +/** + * 단건/복수 조회 시 공통으로 사용 가능한 SELECT 함수 예시 + * - 트랜잭션(tx)을 받아서 사용하도록 구현 + */ +export async function selectProejctAVLs( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(projectApprovedVendors) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** 총 개수 count */ +export async function countProjectAVLs( + tx: PgTransaction<any, any, any>, + where?: any +) { + const res = await tx.select({ count: count() }).from(projectApprovedVendors).where(where); + return res[0]?.count ?? 0; +} + diff --git a/lib/project-avl/service.ts b/lib/project-avl/service.ts new file mode 100644 index 00000000..6ba10c5e --- /dev/null +++ b/lib/project-avl/service.ts @@ -0,0 +1,106 @@ +// src/lib/projectApprovedVendors/service.ts +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { customAlphabet } from "nanoid"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +import { asc, desc, ilike, inArray, and, gte, lte, not, or ,eq} from "drizzle-orm"; +import { GetProjectAVLSchema } from "./validations"; +import { projectApprovedVendors } from "@/db/schema"; +import { countProjectAVLs, selectProejctAVLs } from "./repository"; + + +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 Item 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ +export async function getProjecTAVL(input: GetProjectAVLSchema) { + + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // const advancedTable = input.flags.includes("advancedTable"); + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: projectApprovedVendors, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + + let globalWhere + if (input.search) { + const s = `%${input.search}%` + globalWhere = + or( + ilike(projectApprovedVendors.vendor_name, s), + ilike(projectApprovedVendors.tax_id, s) + , ilike(projectApprovedVendors.vendor_email, s) + , ilike(projectApprovedVendors.vendor_type_name_ko, s) + , ilike(projectApprovedVendors.project_name, s) + , ilike(projectApprovedVendors.project_code, s) + ) + // 필요시 여러 칼럼 OR조건 (status, priority, etc) + } + + const finalWhere = and( + // advancedWhere or your existing conditions + advancedWhere, + globalWhere // and()함수로 결합 or or() 등으로 결합 + ) + + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere + + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(projectApprovedVendors[item.id]) : asc(projectApprovedVendors[item.id]) + ) + : [asc(projectApprovedVendors.approved_at)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectProejctAVLs(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countProjectAVLs(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["project-approved-vendors"], // revalidateTag("projectApprovedVendors") 호출 시 무효화 + } + )(); +} + diff --git a/lib/project-avl/table/proejctAVL-table.tsx b/lib/project-avl/table/proejctAVL-table.tsx new file mode 100644 index 00000000..b9d6e142 --- /dev/null +++ b/lib/project-avl/table/proejctAVL-table.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} 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 "./projectAVL-table-columns" +import { ProjectApprovedVendors } from "@/db/schema" +import { getProjecTAVL } from "../service" + +interface ProjectAVLTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getProjecTAVL>>, + ] + > +} + +export function ProjectAVLTable({ promises }: ProjectAVLTableProps) { + + const [{ data, pageCount }] = + React.use(promises) + + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ProjectApprovedVendors> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<ProjectApprovedVendors>[] = [ + + + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<ProjectApprovedVendors>[] = [ + { + id: "project_code", + label: "프로젝트 코드", + type: "text", + }, + { + id: "project_name", + label: "프로젝트명", + type: "text", + }, + { + id: "vendor_name", + label: "업체명", + type: "text", + }, + { + id: "vendor_code", + label: "업체코드", + type: "text", + }, + { + id: "tax_id", + label: "사업자등록번호", + type: "text", + }, + { + id: "vendor_email", + label: "대표이메일", + type: "text", + }, + { + id: "vendor_phone", + label: "대표전화번호", + type: "text", + }, + { + id: "vendor_type_name_ko", + label: "업체유형", + type: "text", + }, + + + { + id: "submitted_at", + label: "PQ 제출일", + type: "date", + + }, + { + id: "approved_at", + label: "PQ 승인일", + type: "date", + + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "approved_at", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.vendor_id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable + table={table} + + > + + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + </DataTableAdvancedToolbar> + + </DataTable> + + + </> + ) +} diff --git a/lib/project-avl/table/projectAVL-table-columns.tsx b/lib/project-avl/table/projectAVL-table-columns.tsx new file mode 100644 index 00000000..916380e3 --- /dev/null +++ b/lib/project-avl/table/projectAVL-table-columns.tsx @@ -0,0 +1,104 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { InfoIcon } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { formListsColumnsConfig } from "@/config/formListsColumnsConfig" +import { ProjectApprovedVendors } from "@/db/schema" +import { projectAVLColumnsConfig } from "@/config/projectAVLColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ProjectApprovedVendors> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ProjectApprovedVendors>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + + + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<TagTypeClassFormMappings>[] } + const groupMap: Record<string, ColumnDef<ProjectApprovedVendors>[]> = {} + + projectAVLColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<ProjectApprovedVendors> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "submitted_at"||cfg.id === "approved_at") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<ProjectApprovedVendors>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + ...nestedColumns, + ] +}
\ No newline at end of file diff --git a/lib/project-avl/validations.ts b/lib/project-avl/validations.ts new file mode 100644 index 00000000..2d3b262e --- /dev/null +++ b/lib/project-avl/validations.ts @@ -0,0 +1,41 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { ProjectApprovedVendors } from "@/db/schema" + +export const searchProjectAVLParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<ProjectApprovedVendors>().withDefault([ + { id: "approved_at", desc: true }, + ]), + vendor_code: parseAsString.withDefault(""), + vendor_name: parseAsString.withDefault(""), + tax_id: parseAsString.withDefault(""), + vendor_email: parseAsString.withDefault(""), + vendor_phone: parseAsString.withDefault(""), + vendor_status: parseAsString.withDefault(""), + vendor_type_name_ko: parseAsString.withDefault(""), + project_code: parseAsString.withDefault(""), + project_name: parseAsString.withDefault(""), + pq_status: parseAsString.withDefault(""), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + + +export type GetProjectAVLSchema = Awaited<ReturnType<typeof searchProjectAVLParamsCache.parse>> diff --git a/lib/rfqs/cbe-table/cbe-table-columns.tsx b/lib/rfqs/cbe-table/cbe-table-columns.tsx index 325b0465..bc16496f 100644 --- a/lib/rfqs/cbe-table/cbe-table-columns.tsx +++ b/lib/rfqs/cbe-table/cbe-table-columns.tsx @@ -34,8 +34,9 @@ interface GetColumnsProps { React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> > router: NextRouter - openCommentSheet: (vendorId: number) => void - openFilesDialog: (cbeId:number , vendorId: number) => void + openCommentSheet: (responseId: number) => void + openVendorContactsDialog: (vendorId: number, vendor: VendorWithCbeFields) => void // 수정된 시그니처 + } /** @@ -45,7 +46,7 @@ export function getColumns({ setRowAction, router, openCommentSheet, - openFilesDialog + openVendorContactsDialog }: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { // ---------------------------------------------------------------- // 1) Select 컬럼 (체크박스) @@ -104,6 +105,30 @@ export function getColumns({ // 1) 필드값 가져오기 const val = getValue() + if (cfg.id === "vendorName") { + const vendor = row.original; + const vendorId = vendor.vendorId; + + // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 + const handleVendorNameClick = () => { + if (vendorId) { + openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 + } else { + toast.error("협력업체 ID를 찾을 수 없습니다."); + } + }; + + return ( + <Button + variant="link" + className="p-0 h-auto text-left font-normal justify-start hover:underline" + onClick={handleVendorNameClick} + > + {val as string} + </Button> + ); + } + if (cfg.id === "vendorStatus") { const statusVal = row.original.vendorStatus if (!statusVal) return null @@ -116,8 +141,8 @@ export function getColumns({ } - if (cfg.id === "rfqVendorStatus") { - const statusVal = row.original.rfqVendorStatus + if (cfg.id === "responseStatus") { + const statusVal = row.original.responseStatus if (!statusVal) return null // const Icon = getStatusIcon(statusVal) const variant = statusVal ==="INVITED"?"default" :statusVal ==="DECLINED"?"destructive":statusVal ==="ACCEPTED"?"secondary":"outline" @@ -128,8 +153,8 @@ export function getColumns({ ) } - // 예) TBE Updated (날짜) - if (cfg.id === "cbeUpdated") { + // 예) CBE Updated (날짜) + if (cfg.id === "respondedAt" ) { const dateVal = val as Date | undefined if (!dateVal) return null return formatDate(dateVal) @@ -172,39 +197,32 @@ const commentsColumn: ColumnDef<VendorWithCbeFields> = { function handleClick() { // rowAction + openCommentSheet setRowAction({ row, type: "comments" }) - openCommentSheet(vendor.cbeId ?? 0) + openCommentSheet(vendor.responseId ?? 0) } return ( - <div className="flex items-center justify-center"> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 group relative" - onClick={handleClick} - aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"} - > - <div className="flex items-center justify-center relative"> - {commCount > 0 ? ( - <> - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - <Badge - variant="secondary" - className="absolute -top-2 -right-2 h-4 min-w-4 text-xs px-1 flex items-center justify-center" - > - {commCount} - </Badge> - </> - ) : ( - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - )} - </div> - <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span> - </Button> - {/* <span className="ml-2 text-sm text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={handleClick}> - {commCount > 0 ? `${commCount} Comments` : "Add Comment"} - </span> */} - </div> + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {commCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {commCount} + </Badge> + )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> + </Button> ) }, enableSorting: false, diff --git a/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx new file mode 100644 index 00000000..fbcf9af9 --- /dev/null +++ b/lib/rfqs/cbe-table/cbe-table-toolbar-actions.tsx @@ -0,0 +1,67 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + + +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { InviteVendorsDialog } from "./invite-vendors-dialog" + +interface VendorsTableToolbarActionsProps { + table: Table<VendorWithCbeFields> + rfqId: number +} + +export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbarActionsProps) { + // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일이 선택되었을 때 처리 + + function handleImportClick() { + // 숨겨진 <input type="file" /> 요소를 클릭 + fileInputRef.current?.click() + } + + const invitationPossibeVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.commercialResponseStatus === null); + }, [table.getFilteredSelectedRowModel().rows]); + + return ( + <div className="flex items-center gap-2"> + {invitationPossibeVendors.length > 0 && + ( + <InviteVendorsDialog + vendors={invitationPossibeVendors} + rfqId={rfqId} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) + } + + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "tasks", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/cbe-table.tsx b/lib/rfqs/cbe-table/cbe-table.tsx index b2a74466..37fbc3f4 100644 --- a/lib/rfqs/cbe-table/cbe-table.tsx +++ b/lib/rfqs/cbe-table/cbe-table.tsx @@ -8,16 +8,17 @@ import type { DataTableRowAction, } from "@/types/table" -import { toSentenceCase } from "@/lib/utils" 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 { useFeatureFlags } from "./feature-flags-provider" -import { Vendor, vendors } from "@/db/schema/vendors" import { fetchRfqAttachmentsbyCommentId, getCBE } from "../service" -import { TbeComment } from "../tbe-table/comments-sheet" import { getColumns } from "./cbe-table-columns" import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { CommentSheet, CbeComment } from "./comments-sheet" +import { useSession } from "next-auth/react" // Next-auth session hook 추가 +import { VendorContactsDialog } from "./vendor-contact-dialog" +import { InviteVendorsDialog } from "./invite-vendors-dialog" +import { VendorsTableToolbarActions } from "./cbe-table-toolbar-actions" interface VendorsTableProps { promises: Promise< @@ -30,56 +31,54 @@ interface VendorsTableProps { export function CbeTable({ promises, rfqId }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() // Suspense로 받아온 데이터 const [{ data, pageCount }] = React.use(promises) + const { data: session } = useSession() // 세션 정보 가져오기 + + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 + const currentUser = session?.user - console.log(data, "data") const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) // **router** 획득 const router = useRouter() - const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) - const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - - const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) - const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null) + const [isLoadingComments, setIsLoadingComments] = React.useState(false) + // const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - // Add handleRefresh function - const handleRefresh = React.useCallback(() => { - router.refresh(); - }, [router]); + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) + const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) + const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState<VendorWithCbeFields | null>(null) + // console.log("selectedVendorId", selectedVendorId) + // console.log("selectedCbeId", selectedCbeId) React.useEffect(() => { if (rowAction?.type === "comments") { // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 - openCommentSheet(Number(rowAction.row.original.id)) - } else if (rowAction?.type === "files") { - // Handle files action - const vendorId = rowAction.row.original.vendorId; - const cbeId = rowAction.row.original.cbeId ?? 0; - openFilesDialog(cbeId, vendorId); - } + openCommentSheet(Number(rowAction.row.original.responseId)) + } }, [rowAction]) - async function openCommentSheet(vendorId: number) { + async function openCommentSheet(responseId: number) { setInitialComments([]) - + setIsLoadingComments(true) const comments = rowAction?.row.original.comments + // const rfqId = rowAction?.row.original.rfqId + const vendorId = rowAction?.row.original.vendorId if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( + const commentWithAttachments: CbeComment[] = await Promise.all( comments.map(async (c) => { const attachments = await fetchRfqAttachmentsbyCommentId(c.id) return { ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 + commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 attachments, } }) @@ -88,20 +87,22 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) { setInitialComments(commentWithAttachments) } - setSelectedRfqIdForComments(vendorId) + // if(rfqId){ setSelectedRfqIdForComments(rfqId)} + if(vendorId){ setSelectedVendorId(vendorId)} + setSelectedCbeId(responseId) setCommentSheetOpen(true) + setIsLoadingComments(false) } - const openFilesDialog = (cbeId: number, vendorId: number) => { - setSelectedTbeId(cbeId) + const openVendorContactsDialog = (vendorId: number, vendor: VendorWithCbeFields) => { setSelectedVendorId(vendorId) - setIsFileDialogOpen(true) + setSelectedVendor(vendor) + setIsContactDialogOpen(true) } - // getColumns() 호출 시, router를 주입 const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }), + () => getColumns({ setRowAction, router, openCommentSheet, openVendorContactsDialog }), [setRowAction, router] ) @@ -111,18 +112,7 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) { const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ { id: "vendorName", label: "Vendor Name", type: "text" }, { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, - { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + { id: "respondedAt", label: "Updated at", type: "date" }, ] @@ -134,32 +124,55 @@ export function CbeTable({ promises, rfqId }: VendorsTableProps) { enablePinning: true, enableAdvancedFilter: true, initialState: { - sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["actions"] }, + sorting: [{ id: "respondedAt", desc: true }], + columnPinning: { right: ["comments"] }, }, - getRowId: (originalRow) => String(originalRow.id), + getRowId: (originalRow) => String(originalRow.responseId), shallow: false, clearOnDefault: true, }) return ( <> -<div style={{ maxWidth: '80vw' }}> -<DataTable + <DataTable table={table} - // tableContainerClass="sm:max-w-[80vw] md:max-w-[80vw] lg:max-w-[80vw]" - // tableContainerClass="max-w-[80vw]" > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} shallow={false} > - {/* <VendorsTableToolbarActions table={table} rfqId={rfqId} /> */} + <VendorsTableToolbarActions table={table} rfqId={rfqId} /> </DataTableAdvancedToolbar> </DataTable> - </div> - + + <CommentSheet + currentUserId={currentUserId} + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + rfqId={rfqId} + cbeId={selectedCbeId ?? 0} + vendorId={selectedVendorId ?? 0} + isLoading={isLoadingComments} + initialComments={initialComments} + /> + + <InviteVendorsDialog + vendors={rowAction?.row.original ? [rowAction?.row.original] : []} + onOpenChange={() => setRowAction(null)} + rfqId={rfqId} + open={rowAction?.type === "invite"} + showTrigger={false} + currentUser={currentUser} + /> + + <VendorContactsDialog + isOpen={isContactDialogOpen} + onOpenChange={setIsContactDialogOpen} + vendorId={selectedVendorId} + vendor={selectedVendor} + /> + </> ) }
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/comments-sheet.tsx b/lib/rfqs/cbe-table/comments-sheet.tsx new file mode 100644 index 00000000..e91a0617 --- /dev/null +++ b/lib/rfqs/cbe-table/comments-sheet.tsx @@ -0,0 +1,328 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Download, X, Loader2 } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Textarea } from "@/components/ui/textarea" +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput, +} from "@/components/ui/dropzone" +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table" + +import { createRfqCommentWithAttachments } from "../service" +import { formatDate } from "@/lib/utils" + + +export interface CbeComment { + id: number + commentText: string + commentedBy?: number + commentedByEmail?: string + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +// 1) props 정의 +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + initialComments?: CbeComment[] + currentUserId: number + rfqId: number + // tbeId?: number + cbeId?: number + vendorId: number + onCommentsUpdated?: (comments: CbeComment[]) => void + isLoading?: boolean // New prop +} + +// 2) 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional(), // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + // tbeId, + cbeId, + onCommentsUpdated, + isLoading = false, // Default to false + ...props +}: CommentSheetProps) { + + + const [comments, setComments] = React.useState<CbeComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [], + }, + }) + + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles", + }) + + // (A) 기존 코멘트 렌더링 + function renderExistingComments() { + + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {!c.attachments?.length && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments?.length && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // (B) 파일 드롭 + function handleDropAccepted(files: File[]) { + append(files) + } + + // (C) Submit + async function onSubmit(data: CommentFormValues) { + if (!rfqId) return + startTransition(async () => { + try { + // console.log("rfqId", rfqId) + // console.log("vendorId", vendorId) + // console.log("cbeId", cbeId) + // console.log("currentUserId", currentUserId) + + const res = await createRfqCommentWithAttachments({ + rfqId, + vendorId, + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, + cbeId: cbeId, + files: data.newFiles, + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 임시로 새 코멘트 추가 + const newComment: CbeComment = { + id: res.commentId, // 서버 응답 + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: + data.newFiles?.map((f) => ({ + id: Math.floor(Math.random() * 1e6), + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || [], + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea placeholder="Enter your comment..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div + key={field.id} + className="flex items-center justify-between border rounded p-2" + > + <span className="text-sm"> + {file.name} ({prettyBytes(file.size)}) + </span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/invite-vendors-dialog.tsx b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx new file mode 100644 index 00000000..18edbe80 --- /dev/null +++ b/lib/rfqs/cbe-table/invite-vendors-dialog.tsx @@ -0,0 +1,423 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader, Send, User } from "lucide-react" +import { toast } from "sonner" +import { z } from "zod" + +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 { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { type Row } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { createCbeEvaluation } from "../service" + +// 컴포넌트 내부에서 사용할 폼 스키마 정의 +const formSchema = z.object({ + paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), + incoterms: z.string().min(1, "Incoterms를 입력하세요"), + deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), + notes: z.string().optional(), +}) + +type FormValues = z.infer<typeof formSchema> + +interface InviteVendorsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + rfqId: number + vendors: Row<VendorWithCbeFields>["original"][] + currentUserId?: number + currentUser?: { + id: string + name?: string | null + email?: string | null + image?: string | null + companyId?: number | null + domain?: string | null + } + showTrigger?: boolean + onSuccess?: () => void +} + +export function InviteVendorsDialog({ + rfqId, + vendors, + currentUserId, + currentUser, + showTrigger = true, + onSuccess, + ...props +}: InviteVendorsDialogProps) { + const [files, setFiles] = React.useState<FileList | null>(null) + const isDesktop = useMediaQuery("(min-width: 640px)") + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 로컬 스키마와 폼 값을 사용하도록 수정 + const form = useForm<FormValues>({ + resolver: zodResolver(formSchema), + defaultValues: { + paymentTerms: "", + incoterms: "", + deliverySchedule: "", + notes: "", + }, + mode: "onChange", + }) + + // 폼 상태 감시 + const { formState } = form + const isValid = formState.isValid && + !!form.getValues("paymentTerms") && + !!form.getValues("incoterms") && + !!form.getValues("deliverySchedule") + + // 디버깅용 상태 트래킹 + React.useEffect(() => { + const subscription = form.watch((value) => { + // 폼 값이 변경될 때마다 실행되는 콜백 + console.log("Form values changed:", value); + }); + + return () => subscription.unsubscribe(); + }, [form]); + + async function onSubmit(data: FormValues) { + try { + setIsSubmitting(true) + + // 기본 FormData 생성 + const formData = new FormData() + + // rfqId 추가 + formData.append("rfqId", String(rfqId)) + + // 폼 데이터 추가 + Object.entries(data).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + formData.append(key, String(value)) + } + }) + + // 현재 사용자 ID 추가 + if (currentUserId) { + formData.append("evaluatedBy", String(currentUserId)) + } + + // 협력업체 ID만 추가 (서버에서 연락처 정보를 조회) + vendors.forEach((vendor) => { + formData.append("vendorIds[]", String(vendor.vendorId)) + }) + + // 파일 추가 (있는 경우에만) + if (files && files.length > 0) { + for (let i = 0; i < files.length; i++) { + formData.append("files", files[i]) + } + } + + // 서버 액션 호출 + const response = await createCbeEvaluation(formData) + + if (response.error) { + toast.error(response.error) + return + } + + // 성공 처리 + toast.success(`${vendors.length}개 협력업체에 CBE 평가가 성공적으로 전송되었습니다!`) + form.reset() + setFiles(null) + props.onOpenChange?.(false) + onSuccess?.() + } catch (error) { + console.error(error) + toast.error("CBE 평가 생성 중 오류가 발생했습니다.") + } finally { + setIsSubmitting(false) + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + setFiles(null) + } + props.onOpenChange?.(nextOpen) + } + + // 필수 필드 라벨에 추가할 요소 + const RequiredLabel = ( + <span className="text-destructive ml-1 font-medium">*</span> + ) + + const formContent = ( + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 선택된 협력업체 정보 표시 */} + <div className="space-y-2"> + <FormLabel>선택된 협력업체 ({vendors.length})</FormLabel> + <ScrollArea className="h-20 border rounded-md p-2"> + <div className="flex flex-wrap gap-2"> + {vendors.map((vendor, index) => ( + <Badge key={index} variant="secondary" className="py-1"> + {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} + </Badge> + ))} + </div> + </ScrollArea> + <FormDescription> + 선택된 모든 협력업체의 등록된 연락처에게 CBE 평가 알림이 전송됩니다. + </FormDescription> + </div> + + {/* 작성자 정보 (읽기 전용) */} + {currentUser && ( + <div className="border rounded-md p-3 space-y-2"> + <FormLabel>작성자</FormLabel> + <div className="flex items-center gap-3"> + {currentUser.image ? ( + <Avatar className="h-8 w-8"> + <AvatarImage src={currentUser.image} alt={currentUser.name || ""} /> + <AvatarFallback> + {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} + </AvatarFallback> + </Avatar> + ) : ( + <Avatar className="h-8 w-8"> + <AvatarFallback> + {currentUser.name?.charAt(0) || <User className="h-4 w-4" />} + </AvatarFallback> + </Avatar> + )} + <div> + <p className="text-sm font-medium">{currentUser.name || "Unknown User"}</p> + <p className="text-xs text-muted-foreground">{currentUser.email || ""}</p> + </div> + </div> + </div> + )} + + {/* 결제 조건 - 필수 필드 */} + <FormField + control={form.control} + name="paymentTerms" + render={({ field }) => ( + <FormItem> + <FormLabel> + 결제 조건{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: Net 30" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Incoterms - 필수 필드 */} + <FormField + control={form.control} + name="incoterms" + render={({ field }) => ( + <FormItem> + <FormLabel> + Incoterms{RequiredLabel} + </FormLabel> + <FormControl> + <Input {...field} placeholder="예: FOB, CIF" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 배송 일정 - 필수 필드 */} + <FormField + control={form.control} + name="deliverySchedule" + render={({ field }) => ( + <FormItem> + <FormLabel> + 배송 일정{RequiredLabel} + </FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="배송 일정 세부사항을 입력하세요" + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 비고 - 선택적 필드 */} + <FormField + control={form.control} + name="notes" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="추가 비고 사항을 입력하세요" + rows={3} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 첨부 (옵션) */} + <div className="space-y-2"> + <FormLabel htmlFor="files">첨부 파일 (선택사항)</FormLabel> + <Input + id="files" + type="file" + multiple + onChange={(e) => setFiles(e.target.files)} + /> + {files && files.length > 0 && ( + <p className="text-sm text-muted-foreground"> + {files.length}개 파일이 첨부되었습니다 + </p> + )} + </div> + + {/* 필수 입력 항목 안내 */} + <div className="text-sm text-muted-foreground"> + <span className="text-destructive">*</span> 표시는 필수 입력 항목입니다. + </div> + + {/* 모바일에서는 Drawer 내부에서 버튼이 렌더링되므로 여기서는 숨김 */} + {isDesktop && ( + <DialogFooter className="gap-2 pt-4"> + <DialogClose asChild> + <Button + type="button" + variant="outline" + > + 취소 + </Button> + </DialogClose> + <Button + type="submit" + disabled={isSubmitting || !isValid} + > + {isSubmitting && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} + </Button> + </DialogFooter> + )} + </form> + </Form> + ) + + // Desktop Dialog + if (isDesktop) { + return ( + <Dialog {...props} onOpenChange={handleDialogOpenChange}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + CBE 평가 전송 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent className="sm:max-w-[600px]"> + <DialogHeader> + <DialogTitle>CBE 평가 생성 및 전송</DialogTitle> + <DialogDescription> + 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. + </DialogDescription> + </DialogHeader> + + {formContent} + </DialogContent> + </Dialog> + ) + } + + // Mobile Drawer + return ( + <Drawer {...props} onOpenChange={handleDialogOpenChange}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Send className="mr-2 size-4" aria-hidden="true" /> + CBE 평가 전송 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>CBE 평가 생성 및 전송</DrawerTitle> + <DrawerDescription> + 선택한 {vendors.length}개 협력업체에 대한 상업 입찰 평가를 생성하고 알림을 전송합니다. + </DrawerDescription> + </DrawerHeader> + + <div className="px-4"> + {formContent} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + onClick={form.handleSubmit(onSubmit)} + disabled={isSubmitting || !isValid} + > + {isSubmitting && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + {vendors.length > 1 ? `${vendors.length}개 협력업체에 전송` : "전송"} + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/cbe-table/vendor-contact-dialog.tsx b/lib/rfqs/cbe-table/vendor-contact-dialog.tsx new file mode 100644 index 00000000..180db392 --- /dev/null +++ b/lib/rfqs/cbe-table/vendor-contact-dialog.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { VendorContactsTable } from "../tbe-table/vendor-contact/vendor-contact-table" + +interface VendorContactsDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null + vendor: VendorWithCbeFields | null +} + +export function VendorContactsDialog({ + isOpen, + onOpenChange, + vendorId, + vendor, +}: VendorContactsDialogProps) { + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> + <DialogHeader> + <div className="flex flex-col space-y-2"> + <DialogTitle>협력업체 연락처</DialogTitle> + {vendor && ( + <div className="flex flex-col space-y-1 mt-2"> + <div className="text-sm text-muted-foreground"> + <span className="font-medium text-foreground">{vendor.vendorName}</span> + {vendor.vendorCode && ( + <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> + )} + </div> + <div className="flex items-center"> + {vendor.vendorStatus && ( + <Badge variant="outline" className="mr-2"> + {vendor.vendorStatus} + </Badge> + )} + {vendor.commercialResponseStatus && ( + <Badge + variant={ + vendor.commercialResponseStatus === "INVITED" ? "default" : + vendor.commercialResponseStatus === "DECLINED" ? "destructive" : + vendor.commercialResponseStatus === "ACCEPTED" ? "secondary" : "outline" + } + > + {vendor.commercialResponseStatus} + </Badge> + )} + </div> + </div> + )} + </div> + </DialogHeader> + {vendorId && ( + <div className="py-4"> + <VendorContactsTable vendorId={vendorId} /> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/repository.ts b/lib/rfqs/repository.ts index ad44cf07..24d09ec3 100644 --- a/lib/rfqs/repository.ts +++ b/lib/rfqs/repository.ts @@ -1,7 +1,7 @@ // src/lib/tasks/repository.ts import db from "@/db/db"; import { items } from "@/db/schema/items"; -import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses } from "@/db/schema/rfq"; +import { rfqItems, rfqs, RfqWithItems, rfqsView, type Rfq,VendorResponse, vendorResponses, RfqViewWithItems } from "@/db/schema/rfq"; import { users } from "@/db/schema/users"; import { eq, @@ -177,12 +177,12 @@ export async function insertRfqItem( return tx.insert(rfqItems).values(data).returning(); } -export const getRfqById = async (id: number): Promise<RfqWithItems | null> => { +export const getRfqById = async (id: number): Promise<RfqViewWithItems | null> => { // 1) RFQ 단건 조회 const rfqsRes = await db .select() - .from(rfqs) - .where(eq(rfqs.id, id)) + .from(rfqsView) + .where(eq(rfqsView.id, id)) .limit(1); if (rfqsRes.length === 0) return null; @@ -197,7 +197,7 @@ export const getRfqById = async (id: number): Promise<RfqWithItems | null> => { // itemsRes: RfqItem[] // 3) RfqWithItems 형태로 반환 - const result: RfqWithItems = { + const result: RfqViewWithItems = { ...rfqRow, lines: itemsRes, }; diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts index b56349e2..c7d1c3cd 100644 --- a/lib/rfqs/service.ts +++ b/lib/rfqs/service.ts @@ -8,7 +8,7 @@ import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; -import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema } from "./validations"; +import { GetRfqsSchema, CreateRfqSchema, UpdateRfqSchema, CreateRfqItemSchema, GetMatchedVendorsSchema, GetRfqsForVendorsSchema, UpdateRfqVendorSchema, GetTBESchema, RfqType, GetCBESchema, createCbeEvaluationSchema } from "./validations"; import { asc, desc, ilike, inArray, and, gte, lte, not, or, sql, eq, isNull, ne, isNotNull, count } from "drizzle-orm"; import path from "path"; import fs from "fs/promises"; @@ -16,15 +16,16 @@ import { randomUUID } from "crypto"; import { writeFile, mkdir } from 'fs/promises' import { join } from 'path' -import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses } from "@/db/schema/rfq"; +import { vendorResponses, vendorResponsesView, Rfq, rfqs, rfqAttachments, rfqItems, RfqWithItems, rfqComments, rfqEvaluations, vendorRfqView, vendorTbeView, rfqsView, vendorResponseAttachments, vendorTechnicalResponses, vendorCbeView, cbeEvaluations, vendorCommercialResponses, vendorResponseCBEView, RfqViewWithItems } from "@/db/schema/rfq"; import { countRfqs, deleteRfqById, deleteRfqsByIds, getRfqById, groupByStatus, insertRfq, insertRfqItem, selectRfqs, updateRfq, updateRfqs, updateRfqVendor } from "./repository"; import logger from '@/lib/logger'; -import { vendorPossibleItems, vendors } from "@/db/schema/vendors"; +import { vendorContacts, vendorPossibleItems, vendors } from "@/db/schema/vendors"; import { sendEmail } from "../mail/sendEmail"; -import { projects } from "@/db/schema/projects"; +import { biddingProjects, projects } from "@/db/schema/projects"; import { items } from "@/db/schema/items"; import * as z from "zod" import { users } from "@/db/schema/users"; +import { headers } from 'next/headers'; interface InviteVendorsInput { @@ -176,6 +177,7 @@ export async function createRfq(input: CreateRfqSchema) { const [newTask] = await insertRfq(tx, { rfqCode: input.rfqCode, projectId: input.projectId || null, + bidProjectId: input.bidProjectId || null, description: input.description || null, dueDate: input.dueDate, status: input.status, @@ -547,7 +549,7 @@ export async function fetchRfqItems(rfqId: number) { })) } -export const findRfqById = async (id: number): Promise<RfqWithItems | null> => { +export const findRfqById = async (id: number): Promise<RfqViewWithItems | null> => { try { logger.info({ id }, 'Fetching user by ID'); const rfq = await getRfqById(id); @@ -726,13 +728,16 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n // ───────────────────────────────────────────────────── // 5) 코멘트 조회: 기존과 동일 // ───────────────────────────────────────────────────── + console.log("distinctVendorIds", distinctVendorIds) const commAll = await db .select() .from(rfqComments) .where( and( inArray(rfqComments.vendorId, distinctVendorIds), - eq(rfqComments.rfqId, rfqId) + eq(rfqComments.rfqId, rfqId), + isNull(rfqComments.evaluationId), + isNull(rfqComments.cbeId) ) ) @@ -756,7 +761,7 @@ export async function getMatchedVendors(input: GetMatchedVendorsSchema, rfqId: n userMap.set(user.id, user); } - // 댓글 정보를 벤더 ID별로 그룹화하고, 사용자 이메일 추가 + // 댓글 정보를 협력업체 ID별로 그룹화하고, 사용자 이메일 추가 for (const c of commAll) { const vid = c.vendorId! if (!commByVendorId.has(vid)) { @@ -804,6 +809,9 @@ export async function inviteVendors(input: InviteVendorsInput) { throw new Error("Invalid input") } + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + // DB 데이터 준비 및 첨부파일 처리를 위한 트랜잭션 const rfqData = await db.transaction(async (tx) => { // 2-A) RFQ 기본 정보 조회 @@ -869,8 +877,7 @@ export async function inviteVendors(input: InviteVendorsInput) { }) const { rfqRow, items, vendorRows, attachments } = rfqData - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - const loginUrl = `${baseUrl}/en/partners/rfq` + const loginUrl = `http://${host}/en/partners/rfq` // 이메일 전송 오류를 기록할 배열 const emailErrors = [] @@ -878,11 +885,11 @@ export async function inviteVendors(input: InviteVendorsInput) { // 각 벤더에 대해 처리 for (const v of vendorRows) { if (!v.email) { - continue // 이메일 없는 벤더 무시 + continue // 이메일 없는 협력업체 무시 } try { - // DB 업데이트: 각 벤더 상태 별도 트랜잭션 + // DB 업데이트: 각 협력업체 상태 별도 트랜잭션 await db.transaction(async (tx) => { // rfq_vendors upsert const existing = await tx @@ -932,10 +939,10 @@ export async function inviteVendors(input: InviteVendorsInput) { attachments, }) } catch (err) { - // 개별 벤더 처리 실패 로깅 + // 개별 협력업체 처리 실패 로깅 console.error(`Failed to process vendor ${v.id}: ${getErrorMessage(err)}`) emailErrors.push({ vendorId: v.id, error: getErrorMessage(err) }) - // 계속 진행 (다른 벤더 처리) + // 계속 진행 (다른 협력업체 처리) } } @@ -1015,7 +1022,7 @@ export async function getTBE(input: GetTBESchema, rfqId: number) { // 5) finalWhere const finalWhere = and( eq(vendorTbeView.rfqId, rfqId), - notRejected, + // notRejected, advancedWhere, globalWhere ) @@ -1057,6 +1064,12 @@ export async function getTBE(input: GetTBESchema, rfqId: number) { tbeResult: vendorTbeView.tbeResult, tbeNote: vendorTbeView.tbeNote, tbeUpdated: vendorTbeView.tbeUpdated, + + technicalResponseId:vendorTbeView.technicalResponseId, + technicalResponseStatus:vendorTbeView.technicalResponseStatus, + technicalSummary:vendorTbeView.technicalSummary, + technicalNotes:vendorTbeView.technicalNotes, + technicalUpdated:vendorTbeView.technicalUpdated, }) .from(vendorTbeView) .where(finalWhere) @@ -1286,8 +1299,7 @@ export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { const finalWhere = and( isNotNull(vendorTbeView.tbeId), eq(vendorTbeView.vendorId, vendorId), - - notRejected, + // notRejected, advancedWhere, globalWhere ) @@ -1318,6 +1330,12 @@ export async function getTBEforVendor(input: GetTBESchema, vendorId: number) { rfqId: vendorTbeView.rfqId, rfqCode: vendorTbeView.rfqCode, + rfqType:vendorTbeView.rfqType, + rfqStatus:vendorTbeView.rfqStatus, + rfqDescription: vendorTbeView.description, + rfqDueDate: vendorTbeView.dueDate, + + projectCode: vendorTbeView.projectCode, projectName: vendorTbeView.projectName, description: vendorTbeView.description, @@ -1491,7 +1509,6 @@ export async function inviteTbeVendorsAction(formData: FormData) { const vendorIdsRaw = formData.getAll("vendorIds[]") const vendorIds = vendorIdsRaw.map((id) => Number(id)) - // 2) FormData에서 파일들 추출 (multiple) const tbeFiles = formData.getAll("tbeFiles") as File[] if (!rfqId || !vendorIds.length || !tbeFiles.length) { @@ -1500,7 +1517,13 @@ export async function inviteTbeVendorsAction(formData: FormData) { // /public/rfq/[rfqId] 경로 const uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) - + + // 디렉토리가 없다면 생성 + try { + await fs.mkdir(uploadDir, { recursive: true }) + } catch (err) { + console.error("디렉토리 생성 실패:", err) + } // DB 트랜잭션 await db.transaction(async (tx) => { @@ -1532,94 +1555,150 @@ export async function inviteTbeVendorsAction(formData: FormData) { .from(rfqItems) .where(eq(rfqItems.rfqId, rfqId)) - // (C) 대상 벤더들 + // (C) 대상 벤더들 (이메일 정보 확장) const vendorRows = await tx - .select({ id: vendors.id, email: vendors.email }) + .select({ + id: vendors.id, + name: vendors.vendorName, + email: vendors.email, + representativeEmail: vendors.representativeEmail // 대표자 이메일 추가 + }) .from(vendors) .where(sql`${vendors.id} in (${vendorIds})`) - // (D) 모든 TBE 파일 저장 & 이후 벤더 초대 처리 + // (D) 모든 TBE 파일 저장 & 이후 협력업체 초대 처리 // 파일은 한 번만 저장해도 되지만, 각 벤더별로 따로 저장/첨부가 필요하다면 루프를 돌려도 됨. - // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 벤더"에는 동일 파일 목록을 첨부한다는 예시. + // 여기서는 "모든 파일"을 RFQ-DIR에 저장 + "각 협력업체"에는 동일 파일 목록을 첨부한다는 예시. const savedFiles = [] for (const file of tbeFiles) { const originalName = file.name || "tbe-sheet.xlsx" - const savePath = path.join(uploadDir, originalName) + // 파일명 충돌 방지를 위한 타임스탬프 추가 + const timestamp = new Date().getTime() + const fileName = `${timestamp}-${originalName}` + const savePath = path.join(uploadDir, fileName) // 파일 ArrayBuffer → Buffer 변환 후 저장 const arrayBuffer = await file.arrayBuffer() - fs.writeFile(savePath, Buffer.from(arrayBuffer)) + await fs.writeFile(savePath, Buffer.from(arrayBuffer)) // 저장 경로 & 파일명 기록 savedFiles.push({ - fileName: originalName, - filePath: `/rfq/${rfqId}/${originalName}`, // public 이하 경로 + fileName: originalName, // 원본 파일명으로 첨부 + filePath: `/rfq/${rfqId}/${fileName}`, // public 이하 경로 absolutePath: savePath, }) } // (E) 각 벤더별로 TBE 평가 레코드, 초대 처리, 메일 발송 - for (const v of vendorRows) { - if (!v.email) { - // 이메일 없는 경우 로직 (스킵 or throw) + for (const vendor of vendorRows) { + // 1) 협력업체 연락처 조회 - 추가 이메일 수집 + const contacts = await tx + .select({ + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendor.id)) + + // 2) 모든 이메일 주소 수집 및 중복 제거 + const allEmails = new Set<string>() + + // 협력업체 이메일 추가 (있는 경우에만) + if (vendor.email) { + allEmails.add(vendor.email.trim().toLowerCase()) + } + + // 협력업체 대표자 이메일 추가 (있는 경우에만) + if (vendor.representativeEmail) { + allEmails.add(vendor.representativeEmail.trim().toLowerCase()) + } + + // 연락처 이메일 추가 + contacts.forEach(contact => { + if (contact.contactEmail) { + allEmails.add(contact.contactEmail.trim().toLowerCase()) + } + }) + + // 중복이 제거된 이메일 주소 배열로 변환 + const uniqueEmails = Array.from(allEmails) + + if (uniqueEmails.length === 0) { + console.warn(`협력업체 ID ${vendor.id}에 등록된 이메일 주소가 없습니다. TBE 초대를 건너뜁니다.`) continue } - // 1) TBE 평가 레코드 생성 + // 3) TBE 평가 레코드 생성 const [evalRow] = await tx .insert(rfqEvaluations) .values({ rfqId, - vendorId: v.id, + vendorId: vendor.id, evalType: "TBE", }) .returning({ id: rfqEvaluations.id }) - // 2) rfqAttachments에 저장한 파일들을 기록 + // 4) rfqAttachments에 저장한 파일들을 기록 for (const sf of savedFiles) { await tx.insert(rfqAttachments).values({ rfqId, - // vendorId: v.id, + vendorId: vendor.id, evaluationId: evalRow.id, fileName: sf.fileName, filePath: sf.filePath, }) } - // 4) 메일 발송 + // 5) 각 고유 이메일 주소로 초대 메일 발송 const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' const loginUrl = `${baseUrl}/ko/partners/rfq` - await sendEmail({ - to: v.email, - subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, - template: "rfq-invite", - context: { - language: "en", - rfqId, - vendorId: v.id, - - rfqCode: rfqRow.rfqCode, - projectCode: rfqRow.projectCode, - projectName: rfqRow.projectName, - dueDate: rfqRow.dueDate, - description: rfqRow.description, - - items: items.map((it) => ({ - itemCode: it.itemCode, - description: it.description, - quantity: it.quantity, - uom: it.uom, - })), - loginUrl, - }, - attachments: savedFiles.map((sf) => ({ - path: sf.absolutePath, - filename: sf.fileName, - })), - }) + + console.log(`협력업체 ID ${vendor.id}(${vendor.name})에 대해 ${uniqueEmails.length}개의 고유 이메일로 TBE 초대 발송`) + + for (const email of uniqueEmails) { + try { + // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) + const contact = contacts.find(c => + c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() + ) + const contactName = contact?.contactName || `${vendor.name} 담당자` + + await sendEmail({ + to: email, + subject: `[RFQ ${rfqRow.rfqCode}] You are invited for TBE!`, + template: "rfq-invite", + context: { + language: "en", + rfqId, + vendorId: vendor.id, + contactName, // 연락처 이름 추가 + rfqCode: rfqRow.rfqCode, + projectCode: rfqRow.projectCode, + projectName: rfqRow.projectName, + dueDate: rfqRow.dueDate, + description: rfqRow.description, + items: items.map((it) => ({ + itemCode: it.itemCode, + description: it.description, + quantity: it.quantity, + uom: it.uom, + })), + loginUrl, + }, + attachments: savedFiles.map((sf) => ({ + path: sf.absolutePath, + filename: sf.fileName, + })), + }) + console.log(`이메일 전송 성공: ${email} (${contactName})`) + } catch (emailErr) { + console.error(`이메일 전송 실패 (${email}):`, emailErr) + } + } } - // 5) 캐시 무효화 + // 6) 캐시 무효화 revalidateTag("tbe-vendors") }) @@ -1662,8 +1741,8 @@ export async function createRfqCommentWithAttachments(params: { files?: File[] }) { const { rfqId, vendorId, commentText, commentedBy, evaluationId,cbeId, files } = params - - + console.log("cbeId", cbeId) + console.log("evaluationId", evaluationId) // 1) 새로운 코멘트 생성 const [insertedComment] = await db .insert(rfqComments) @@ -1797,6 +1876,37 @@ export async function getProjects(): Promise<Project[]> { } +export async function getBidProjects(): Promise<Project[]> { + try { + // 트랜잭션을 사용하여 프로젝트 데이터 조회 + const projectList = await db.transaction(async (tx) => { + // 모든 프로젝트 조회 + const results = await tx + .select({ + id: biddingProjects.id, + projectCode: biddingProjects.pspid, + projectName: biddingProjects.projNm, + }) + .from(biddingProjects) + .orderBy(biddingProjects.id); + + return results; + }); + + // Handle null projectName values + const validProjectList = projectList.map(project => ({ + ...project, + projectName: project.projectName || '' // Replace null with empty string + })); + + return validProjectList; + } catch (error) { + console.error("프로젝트 목록 가져오기 실패:", error); + return []; // 오류 발생 시 빈 배열 반환 + } +} + + // 반환 타입 명시적 정의 - rfqCode가 null일 수 있음을 반영 export interface BudgetaryRfq { id: number; @@ -1919,6 +2029,19 @@ export async function getAllVendors() { return allVendors } + +export async function getVendorContactsByVendorId(vendorId: number) { + try { + const contacts = await db.query.vendorContacts.findMany({ + where: eq(vendorContacts.vendorId, vendorId), + }); + + return { success: true, data: contacts }; + } catch (error) { + console.error("Error fetching vendor contacts:", error); + return { success: false, error: "Failed to fetch vendor contacts" }; + } +} /** * Server action to associate items from an RFQ with a vendor * @@ -2020,8 +2143,6 @@ export async function addItemToVendors(rfqId: number, vendorIds: number[]) { * evaluationId가 일치하고 vendorId가 null인 파일 목록 */ export async function fetchTbeTemplateFiles(evaluationId: number) { - - console.log(evaluationId, "evaluationId") try { const files = await db .select({ @@ -2051,10 +2172,7 @@ export async function fetchTbeTemplateFiles(evaluationId: number) { } } -/** - * 특정 TBE 템플릿 파일 다운로드를 위한 정보 조회 - */ -export async function getTbeTemplateFileInfo(fileId: number) { +export async function getFileFromRfqAttachmentsbyid(fileId: number) { try { const file = await db .select({ @@ -2128,6 +2246,7 @@ export async function uploadTbeResponseFile(formData: FormData) { responseId: vendorResponseId, summary: "TBE 응답 파일 업로드", // 필요에 따라 수정 notes: `파일명: ${originalName}`, + responseStatus:"SUBMITTED" }) .returning({ id: vendorTechnicalResponses.id }); @@ -2354,7 +2473,9 @@ export async function getAllTBE(input: GetTBESchema) { rfqVendorStatus: vendorTbeView.rfqVendorStatus, rfqVendorUpdated: vendorTbeView.rfqVendorUpdated, + technicalResponseStatus:vendorTbeView.technicalResponseStatus, tbeResult: vendorTbeView.tbeResult, + tbeNote: vendorTbeView.tbeNote, tbeUpdated: vendorTbeView.tbeUpdated, }) @@ -2562,9 +2683,6 @@ export async function getAllTBE(input: GetTBESchema) { } - - - export async function getCBE(input: GetCBESchema, rfqId: number) { return unstable_cache( async () => { @@ -2574,7 +2692,7 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { // [2] 고급 필터 const advancedWhere = filterColumns({ - table: vendorCbeView, + table: vendorResponseCBEView, filters: input.filters ?? [], joinOperator: input.joinOperator ?? "and", }); @@ -2584,73 +2702,83 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { if (input.search) { const s = `%${input.search}%`; globalWhere = or( - sql`${vendorCbeView.vendorName} ILIKE ${s}`, - sql`${vendorCbeView.vendorCode} ILIKE ${s}`, - sql`${vendorCbeView.email} ILIKE ${s}` + sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, + sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` ); } - // [4] REJECTED 아니거나 NULL - const notRejected = or( - ne(vendorCbeView.rfqVendorStatus, "REJECTED"), - isNull(vendorCbeView.rfqVendorStatus) - ); + // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) + const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); - // [5] 최종 where + // [5] 최종 where 조건 const finalWhere = and( - eq(vendorCbeView.rfqId, rfqId), - notRejected, - advancedWhere, - globalWhere + eq(vendorResponseCBEView.rfqId, rfqId), + notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined ); // [6] 정렬 const orderBy = input.sort?.length ? input.sort.map((s) => { - // vendor_cbe_view 컬럼 중 정렬 대상이 되는 것만 매핑 - const col = (vendorCbeView as any)[s.id]; - return s.desc ? desc(col) : asc(col); - }) - : [asc(vendorCbeView.vendorId)]; + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 벤더명 // [7] 메인 SELECT const [rows, total] = await db.transaction(async (tx) => { const data = await tx .select({ - // 필요한 컬럼만 추출 - id: vendorCbeView.vendorId, - cbeId: vendorCbeView.cbeId, - vendorId: vendorCbeView.vendorId, - vendorName: vendorCbeView.vendorName, - vendorCode: vendorCbeView.vendorCode, - address: vendorCbeView.address, - country: vendorCbeView.country, - email: vendorCbeView.email, - website: vendorCbeView.website, - vendorStatus: vendorCbeView.vendorStatus, - - rfqId: vendorCbeView.rfqId, - rfqCode: vendorCbeView.rfqCode, - projectCode: vendorCbeView.projectCode, - projectName: vendorCbeView.projectName, - description: vendorCbeView.description, - dueDate: vendorCbeView.dueDate, - - rfqVendorStatus: vendorCbeView.rfqVendorStatus, - rfqVendorUpdated: vendorCbeView.rfqVendorUpdated, - - cbeResult: vendorCbeView.cbeResult, - cbeNote: vendorCbeView.cbeNote, - cbeUpdated: vendorCbeView.cbeUpdated, - - // 상업평가 정보 - totalCost: vendorCbeView.totalCost, - currency: vendorCbeView.currency, - paymentTerms: vendorCbeView.paymentTerms, - incoterms: vendorCbeView.incoterms, - deliverySchedule: vendorCbeView.deliverySchedule, + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, }) - .from(vendorCbeView) + .from(vendorResponseCBEView) .where(finalWhere) .orderBy(...orderBy) .offset(offset) @@ -2658,122 +2786,89 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { const [{ count }] = await tx .select({ count: sql<number>`count(*)`.as("count") }) - .from(vendorCbeView) + .from(vendorResponseCBEView) .where(finalWhere); return [data, Number(count)]; }); if (!rows.length) { - return { data: [], pageCount: 0 }; + return { data: [], pageCount: 0, total: 0 }; } - // [8] Comments 조회 - // TBE 에서는 rfqComments + rfqEvaluations(evalType="TBE") 를 조인했지만, - // CBE는 cbeEvaluations 또는 evalType="CBE"를 기준으로 바꾸면 됩니다. - // 만약 cbeEvaluations.id 를 evaluationId 로 참조한다면 아래와 같이 innerJoin: + // [8] 협력업체 ID 목록 추출 const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId))]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; - const commAll = await db + // [9] CBE 평가 관련 코멘트 조회 + const commentsAll = await db .select({ id: rfqComments.id, commentText: rfqComments.commentText, vendorId: rfqComments.vendorId, - evaluationId: rfqComments.evaluationId, + cbeId: rfqComments.cbeId, createdAt: rfqComments.createdAt, commentedBy: rfqComments.commentedBy, - // cbeEvaluations에는 evalType 컬럼이 별도로 없을 수도 있음(프로젝트 구조에 맞게 수정) - // evalType: cbeEvaluations.evalType, }) .from(rfqComments) .innerJoin( - cbeEvaluations, - eq(cbeEvaluations.id, rfqComments.evaluationId) + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) ) .where( and( - isNotNull(rfqComments.evaluationId), + isNotNull(rfqComments.cbeId), eq(rfqComments.rfqId, rfqId), inArray(rfqComments.vendorId, distinctVendorIds) ) ); - // vendorId -> comments grouping - const commByVendorId = new Map<number, any[]>(); - for (const c of commAll) { - const vid = c.vendorId!; - if (!commByVendorId.has(vid)) { - commByVendorId.set(vid, []); + // vendorId별 코멘트 그룹화 + const commentsByVendorId = new Map<number, any[]>(); + for (const comment of commentsAll) { + const vendorId = comment.vendorId!; + if (!commentsByVendorId.has(vendorId)) { + commentsByVendorId.set(vendorId, []); } - commByVendorId.get(vid)!.push({ - id: c.id, - commentText: c.commentText, - vendorId: c.vendorId, - evaluationId: c.evaluationId, - createdAt: c.createdAt, - commentedBy: c.commentedBy, + commentsByVendorId.get(vendorId)!.push({ + id: comment.id, + commentText: comment.commentText, + vendorId: comment.vendorId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, }); } - // [9] CBE 파일 조회 (프로젝트에 따라 구조가 달라질 수 있음) - // - TBE는 vendorTechnicalResponses 기준 - // - CBE는 vendorCommercialResponses(가정) 등이 있을 수 있음 - // - 여기서는 예시로 "동일한 vendorResponses + vendorResponseAttachments" 라고 가정 - // Step 1: vendorResponses 가져오기 (rfqId + vendorIds) - const responsesAll = await db + // [10] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db .select({ - id: vendorResponses.id, - vendorId: vendorResponses.vendorId, + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, }) - .from(vendorResponses) + .from(vendorResponseAttachments) .where( and( - eq(vendorResponses.rfqId, rfqId), - inArray(vendorResponses.vendorId, distinctVendorIds) + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) ) ); - // Group responses by vendorId - const responsesByVendorId = new Map<number, number[]>(); - for (const resp of responsesAll) { - if (!responsesByVendorId.has(resp.vendorId)) { - responsesByVendorId.set(resp.vendorId, []); - } - responsesByVendorId.get(resp.vendorId)!.push(resp.id); - } - - // Step 2: responseIds - const allResponseIds = responsesAll.map((r) => r.id); - - - const commercialResponsesAll = await db - .select({ - id: vendorCommercialResponses.id, - responseId: vendorCommercialResponses.responseId, - }) - .from(vendorCommercialResponses) - .where(inArray(vendorCommercialResponses.responseId, allResponseIds)); - - const commercialResponseIdsByResponseId = new Map<number, number[]>(); - for (const cr of commercialResponsesAll) { - if (!commercialResponseIdsByResponseId.has(cr.responseId)) { - commercialResponseIdsByResponseId.set(cr.responseId, []); - } - commercialResponseIdsByResponseId.get(cr.responseId)!.push(cr.id); - } - - const allCommercialResponseIds = commercialResponsesAll.map((cr) => cr.id); - - - // 여기서는 예시로 TBE와 마찬가지로 vendorResponseAttachments를 - // 직접 responseId로 관리한다고 가정(혹은 commercialResponseId로 연결) - // Step 3: vendorResponseAttachments 조회 - const filesAll = await db + // [11] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db .select({ id: vendorResponseAttachments.id, fileName: vendorResponseAttachments.fileName, filePath: vendorResponseAttachments.filePath, - responseId: vendorResponseAttachments.responseId, + commercialResponseId: vendorResponseAttachments.commercialResponseId, fileType: vendorResponseAttachments.fileType, attachmentType: vendorResponseAttachments.attachmentType, description: vendorResponseAttachments.description, @@ -2783,19 +2878,20 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { .from(vendorResponseAttachments) .where( and( - inArray(vendorResponseAttachments.responseId, allCommercialResponseIds), - isNotNull(vendorResponseAttachments.responseId) + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) ) ); - // Step 4: responseId -> files + // [12] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 const filesByResponseId = new Map<number, any[]>(); - for (const file of filesAll) { - const rid = file.responseId!; - if (!filesByResponseId.has(rid)) { - filesByResponseId.set(rid, []); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); } - filesByResponseId.get(rid)!.push({ + filesByResponseId.get(responseId)!.push({ id: file.id, fileName: file.fileName, filePath: file.filePath, @@ -2804,40 +2900,66 @@ export async function getCBE(input: GetCBESchema, rfqId: number) { description: file.description, uploadedAt: file.uploadedAt, uploadedBy: file.uploadedBy, + attachmentSource: 'response' }); } - // Step 5: vendorId -> files - const filesByVendorId = new Map<number, any[]>(); - for (const [vendorId, responseIds] of responsesByVendorId.entries()) { - filesByVendorId.set(vendorId, []); - for (const responseId of responseIds) { - const files = filesByResponseId.get(responseId) || []; - filesByVendorId.get(vendorId)!.push(...files); + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); } + filesByCommercialResponseId.get(commercialResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'commercial' + }); } - // [10] 최종 데이터 합치기 - const final = rows.map((row) => ({ - ...row, - dueDate: row.dueDate ? new Date(row.dueDate) : null, - comments: commByVendorId.get(row.vendorId) ?? [], - files: filesByVendorId.get(row.vendorId) ?? [], - })); + // [13] 최종 데이터 병합 + const final = rows.map((row) => { + // 해당 응답의 모든 첨부파일 가져오기 + const responseFiles = filesByResponseId.get(row.responseId) || []; + const commercialFiles = row.commercialResponseId + ? filesByCommercialResponseId.get(row.commercialResponseId) || [] + : []; + + // 모든 첨부파일 병합 + const allFiles = [...responseFiles, ...commercialFiles]; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByVendorId.get(row.vendorId) || [], + files: allFiles, + }; + }); const pageCount = Math.ceil(total / limit); - return { data: final, pageCount }; + return { + data: final, + pageCount, + total + }; }, // 캐싱 키 & 옵션 - [JSON.stringify({ input, rfqId })], + [`cbe-vendors-${rfqId}-${JSON.stringify(input)}`], { revalidate: 3600, - tags: ["cbe-vendors"], + tags: [`cbe-vendors-${rfqId}`], } )(); } - export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> { try { if (!rfqType) { @@ -2880,4 +3002,1026 @@ export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: str console.error('Error generating next RFQ code:', error); return { code: "", error: '코드 생성에 실패했습니다' }; } +} + +interface SaveTbeResultParams { + id: number // id from the rfq_evaluations table + vendorId: number // vendorId from the rfq_evaluations table + result: string // The selected evaluation result + notes: string // The evaluation notes +} + +export async function saveTbeResult({ + id, + vendorId, + result, + notes, +}: SaveTbeResultParams) { + try { + // Check if we have all required data + if (!id || !vendorId || !result) { + return { + success: false, + message: "Missing required data for evaluation update", + } + } + + // Update the record in the database + await db + .update(rfqEvaluations) + .set({ + result: result, + notes: notes, + updatedAt: new Date(), + }) + .where( + and( + eq(rfqEvaluations.id, id), + eq(rfqEvaluations.vendorId, vendorId), + eq(rfqEvaluations.evalType, "TBE") + ) + ) + + // Revalidate the tbe-vendors tag to refresh the data + revalidateTag("tbe-vendors") + revalidateTag("all-tbe-vendors") + + return { + success: true, + message: "TBE evaluation updated successfully", + } + } catch (error) { + console.error("Failed to update TBE evaluation:", error) + + return { + success: false, + message: error instanceof Error ? error.message : "An unknown error occurred", + } + } +} + + +export async function createCbeEvaluation(formData: FormData) { + try { + // 폼 데이터 추출 + const rfqId = Number(formData.get("rfqId")) + const vendorIds = formData.getAll("vendorIds[]").map(id => Number(id)) + const evaluatedBy = formData.get("evaluatedBy") ? Number(formData.get("evaluatedBy")) : null + + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 기본 CBE 데이터 추출 + const rawData = { + rfqId, + paymentTerms: formData.get("paymentTerms") as string, + incoterms: formData.get("incoterms") as string, + deliverySchedule: formData.get("deliverySchedule") as string, + notes: formData.get("notes") as string, + // 단일 협력업체 처리 시 사용할 vendorId (여러 협력업체 처리에선 사용하지 않음) + // vendorId: vendorIds[0] || 0, + } + + // zod 스키마 유효성 검사 (vendorId는 더미로 채워 검증하고 실제로는 배열로 처리) + const validationResult = createCbeEvaluationSchema.safeParse(rawData) + if (!validationResult.success) { + const errors = validationResult.error.format() + console.error("Validation errors:", errors) + return { error: "입력 데이터가 유효하지 않습니다." } + } + + const validData = validationResult.data + + // RFQ 정보 조회 + const [rfqInfo] = await db + .select({ + rfqCode: rfqsView.rfqCode, + projectCode: rfqsView.projectCode, + projectName: rfqsView.projectName, + dueDate: rfqsView.dueDate, + description: rfqsView.description, + }) + .from(rfqsView) + .where(eq(rfqsView.id, rfqId)) + + if (!rfqInfo) { + return { error: "RFQ 정보를 찾을 수 없습니다." } + } + + // 파일 처리 준비 + const files = formData.getAll("files") as File[] + const hasFiles = files && files.length > 0 && files[0].size > 0 + + // 파일 저장을 위한 디렉토리 생성 (파일이 있는 경우에만) + let uploadDir = "" + if (hasFiles) { + uploadDir = path.join(process.cwd(), "public", "rfq", String(rfqId)) + try { + await fs.mkdir(uploadDir, { recursive: true }) + } catch (err) { + console.error("디렉토리 생성 실패:", err) + return { error: "파일 업로드를 위한 디렉토리 생성에 실패했습니다." } + } + } + + // 첨부 파일 정보를 저장할 배열 + const attachments: { filename: string; path: string }[] = [] + + // 파일이 있는 경우, 파일을 저장하고 첨부 파일 정보 준비 + if (hasFiles) { + for (const file of files) { + if (file.size > 0) { + const originalFilename = file.name + const fileExtension = path.extname(originalFilename) + const timestamp = new Date().getTime() + const safeFilename = `cbe-${rfqId}-${timestamp}${fileExtension}` + const filePath = path.join("rfq", String(rfqId), safeFilename) + const fullPath = path.join(process.cwd(), "public", filePath) + + try { + // File을 ArrayBuffer로 변환하여 파일 시스템에 저장 + const arrayBuffer = await file.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + await fs.writeFile(fullPath, buffer) + + // 첨부 파일 정보 추가 + attachments.push({ + filename: originalFilename, + path: fullPath, // 이메일 첨부를 위한 전체 경로 + }) + } catch (err) { + console.error(`파일 저장 실패:`, err) + // 파일 저장 실패를 기록하지만 전체 프로세스는 계속 진행 + } + } + } + } + + // 각 벤더별로 CBE 평가 레코드 생성 및 알림 전송 + const createdCbeIds: number[] = [] + const failedVendors: { id: number, reason: string }[] = [] + + for (const vendorId of vendorIds) { + try { + // 협력업체 정보 조회 (이메일 포함) + const [vendorInfo] = await db + .select({ + id: vendors.id, + name: vendors.vendorName, + vendorCode: vendors.vendorCode, + email: vendors.email, // 협력업체 자체 이메일 추가 + representativeEmail: vendors.representativeEmail, // 협력업체 대표자 이메일 추가 + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + + if (!vendorInfo) { + failedVendors.push({ id: vendorId, reason: "협력업체 정보를 찾을 수 없습니다." }) + continue + } + + // 기존 협력업체 응답 레코드 찾기 + const existingResponse = await db + .select({ id: vendorResponses.id }) + .from(vendorResponses) + .where( + and( + eq(vendorResponses.rfqId, rfqId), + eq(vendorResponses.vendorId, vendorId) + ) + ) + .limit(1) + + if (existingResponse.length === 0) { + console.error(`협력업체 ID ${vendorId}에 대한 응답 레코드가 존재하지 않습니다.`) + failedVendors.push({ id: vendorId, reason: "협력업체 응답 레코드를 찾을 수 없습니다" }) + continue // 다음 벤더로 넘어감 + } + + // 1. CBE 평가 레코드 생성 + const [newCbeEvaluation] = await db + .insert(cbeEvaluations) + .values({ + rfqId, + vendorId, + evaluatedBy, + result: "PENDING", // 초기 상태는 PENDING으로 설정 + totalCost: 0, // 초기값은 0으로 설정 + currency: "USD", // 기본 통화 설정 + paymentTerms: validData.paymentTerms || null, + incoterms: validData.incoterms || null, + deliverySchedule: validData.deliverySchedule || null, + notes: validData.notes || null, + }) + .returning({ id: cbeEvaluations.id }) + + if (!newCbeEvaluation?.id) { + failedVendors.push({ id: vendorId, reason: "CBE 평가 생성 실패" }) + continue + } + + // 2. 상업 응답 레코드 생성 + const [newCbeResponse] = await db + .insert(vendorCommercialResponses) + .values({ + responseId: existingResponse[0].id, + responseStatus: "PENDING", + currency: "USD", + paymentTerms: validData.paymentTerms || null, + incoterms: validData.incoterms || null, + deliveryPeriod: validData.deliverySchedule || null, + }) + .returning({ id: vendorCommercialResponses.id }) + + if (!newCbeResponse?.id) { + failedVendors.push({ id: vendorId, reason: "상업 응답 생성 실패" }) + continue + } + + createdCbeIds.push(newCbeEvaluation.id) + + // 3. 첨부 파일이 있는 경우, 데이터베이스에 첨부 파일 레코드 생성 + if (hasFiles) { + for (let i = 0; i < attachments.length; i++) { + const attachment = attachments[i] + + await db.insert(rfqAttachments).values({ + rfqId, + vendorId, + fileName: attachment.filename, + filePath: `/${path.relative(path.join(process.cwd(), "public"), attachment.path)}`, // URL 경로를 위해 public 기준 상대 경로로 저장 + cbeId: newCbeEvaluation.id, + }) + } + } + + // 4. 협력업체 연락처 조회 + const contacts = await db + .select({ + contactName: vendorContacts.contactName, + contactEmail: vendorContacts.contactEmail, + isPrimary: vendorContacts.isPrimary, + }) + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + + // 5. 모든 이메일 주소 수집 및 중복 제거 + const allEmails = new Set<string>() + + // 연락처 이메일 추가 + contacts.forEach(contact => { + if (contact.contactEmail) { + allEmails.add(contact.contactEmail.trim().toLowerCase()) + } + }) + + // 협력업체 자체 이메일 추가 (있는 경우에만) + if (vendorInfo.email) { + allEmails.add(vendorInfo.email.trim().toLowerCase()) + } + + // 협력업체 대표자 이메일 추가 (있는 경우에만) + if (vendorInfo.representativeEmail) { + allEmails.add(vendorInfo.representativeEmail.trim().toLowerCase()) + } + + // 중복이 제거된 이메일 주소 배열로 변환 + const uniqueEmails = Array.from(allEmails) + + if (uniqueEmails.length === 0) { + console.warn(`협력업체 ID ${vendorId}에 등록된 이메일 주소가 없습니다.`) + } else { + console.log(`협력업체 ID ${vendorId}에 대해 ${uniqueEmails.length}개의 고유 이메일 주소로 알림을 전송합니다.`) + + // 이메일 발송에 필요한 공통 데이터 준비 + const emailData = { + rfqId, + cbeId: newCbeEvaluation.id, + vendorId, + rfqCode: rfqInfo.rfqCode, + projectCode: rfqInfo.projectCode, + projectName: rfqInfo.projectName, + dueDate: rfqInfo.dueDate, + description: rfqInfo.description, + vendorName: vendorInfo.name, + vendorCode: vendorInfo.vendorCode, + paymentTerms: validData.paymentTerms, + incoterms: validData.incoterms, + deliverySchedule: validData.deliverySchedule, + notes: validData.notes, + loginUrl: `http://${host}/en/partners/cbe` + } + + // 각 고유 이메일 주소로 이메일 발송 + for (const email of uniqueEmails) { + try { + // 연락처 이름 찾기 (이메일과 일치하는 연락처가 있으면 사용, 없으면 '벤더명 담당자'로 대체) + const contact = contacts.find(c => + c.contactEmail && c.contactEmail.toLowerCase() === email.toLowerCase() + ) + const contactName = contact?.contactName || `${vendorInfo.name} 담당자` + + await sendEmail({ + to: email, + subject: `[RFQ ${rfqInfo.rfqCode}] 상업 입찰 평가 (CBE) 알림`, + template: "cbe-invitation", + context: { + language: "ko", // 또는 다국어 처리를 위한 설정 + contactName, + ...emailData, + }, + attachments: attachments, + }) + console.log(`이메일 전송 성공: ${email}`) + } catch (emailErr) { + console.error(`이메일 전송 실패 (${email}):`, emailErr) + } + } + } + + } catch (err) { + console.error(`협력업체 ID ${vendorId}의 CBE 생성 실패:`, err) + failedVendors.push({ id: vendorId, reason: "예기치 않은 오류" }) + } + } + + // UI 업데이트를 위한 경로 재검증 + revalidatePath(`/rfq/${rfqId}`) + revalidateTag(`cbe-vendors-${rfqId}`) + + // 결과 반환 + if (createdCbeIds.length === 0) { + return { error: "어떤 벤더에 대해서도 CBE 평가를 생성하지 못했습니다." } + } + + return { + success: true, + cbeIds: createdCbeIds, + totalCreated: createdCbeIds.length, + totalFailed: failedVendors.length, + failedVendors: failedVendors.length > 0 ? failedVendors : undefined + } + + } catch (error) { + console.error("CBE 평가 생성 중 오류 발생:", error) + return { error: "예상치 못한 오류가 발생했습니다." } + } +} + +export async function getCBEbyVendorId(input: GetCBESchema, vendorId: number) { + return unstable_cache( + async () => { + // [1] 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); + const limit = input.perPage ?? 10; + + // [2] 고급 필터 + const advancedWhere = filterColumns({ + table: vendorResponseCBEView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // [3] 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` + ); + } + + // [4] DECLINED 상태 제외 (거절된 응답은 표시하지 않음) + // const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); + + // [5] 최종 where 조건 + const finalWhere = and( + eq(vendorResponseCBEView.vendorId, vendorId), // vendorId로 필터링 + isNotNull(vendorResponseCBEView.commercialCreatedAt), + // notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined + ); + + // [6] 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(vendorResponseCBEView.rfqDueDate)]; // 기본 정렬은 RFQ 마감일 내림차순 + + // [7] 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, + }) + .from(vendorResponseCBEView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorResponseCBEView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + if (!rows.length) { + return { data: [], pageCount: 0, total: 0 }; + } + + // [8] RFQ ID 목록 추출 + const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId))]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId))]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; + + // [9] CBE 평가 관련 코멘트 조회 + const commentsAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + rfqId: rfqComments.rfqId, + cbeId: rfqComments.cbeId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + }) + .from(rfqComments) + .innerJoin( + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) + ) + .where( + and( + isNotNull(rfqComments.cbeId), + eq(rfqComments.vendorId, vendorId), + inArray(rfqComments.rfqId, distinctRfqIds) + ) + ); + + // rfqId별 코멘트 그룹화 + const commentsByRfqId = new Map<number, any[]>(); + for (const comment of commentsAll) { + const rfqId = comment.rfqId!; + if (!commentsByRfqId.has(rfqId)) { + commentsByRfqId.set(rfqId, []); + } + commentsByRfqId.get(rfqId)!.push({ + id: comment.id, + commentText: comment.commentText, + rfqId: comment.rfqId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, + }); + } + + // [10] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) + ) + ); + + // [11] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + commercialResponseId: vendorResponseAttachments.commercialResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) + ) + ); + + // [12] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 + const filesByResponseId = new Map<number, any[]>(); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); + } + filesByResponseId.get(responseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'response' + }); + } + + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); + } + filesByCommercialResponseId.get(commercialResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'commercial' + }); + } + + // [13] 최종 데이터 병합 + const final = rows.map((row) => { + // 해당 응답의 모든 첨부파일 가져오기 + const responseFiles = filesByResponseId.get(row.responseId) || []; + const commercialFiles = row.commercialResponseId + ? filesByCommercialResponseId.get(row.commercialResponseId) || [] + : []; + + // 모든 첨부파일 병합 + const allFiles = [...responseFiles, ...commercialFiles]; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByRfqId.get(row.rfqId) || [], + files: allFiles, + }; + }); + + const pageCount = Math.ceil(total / limit); + return { + data: final, + pageCount, + total + }; + }, + // 캐싱 키 & 옵션 + [`cbe-vendor-${vendorId}-${JSON.stringify(input)}`], + { + revalidate: 3600, + tags: [`cbe-vendor-${vendorId}`], + } + )(); +} + +export async function fetchCbeFiles(vendorId: number, rfqId: number) { + try { + // 1. 먼저 해당 RFQ와 벤더에 해당하는 CBE 평가 레코드를 찾습니다. + const cbeEval = await db + .select({ id: cbeEvaluations.id }) + .from(cbeEvaluations) + .where( + and( + eq(cbeEvaluations.rfqId, rfqId), + eq(cbeEvaluations.vendorId, vendorId) + ) + ) + .limit(1) + + if (!cbeEval.length) { + return { + files: [], + error: "해당 RFQ와 벤더에 대한 CBE 평가를 찾을 수 없습니다." + } + } + + const cbeId = cbeEval[0].id + + // 2. 관련 첨부 파일을 조회합니다. + // - commentId와 evaluationId는 null이어야 함 + // - rfqId와 vendorId가 일치해야 함 + // - cbeId가 위에서 찾은 CBE 평가 ID와 일치해야 함 + const files = await db + .select({ + id: rfqAttachments.id, + fileName: rfqAttachments.fileName, + filePath: rfqAttachments.filePath, + createdAt: rfqAttachments.createdAt + }) + .from(rfqAttachments) + .where( + and( + eq(rfqAttachments.rfqId, rfqId), + eq(rfqAttachments.vendorId, vendorId), + eq(rfqAttachments.cbeId, cbeId), + isNull(rfqAttachments.commentId), + isNull(rfqAttachments.evaluationId) + ) + ) + .orderBy(rfqAttachments.createdAt) + + return { + files, + cbeId + } + } catch (error) { + console.error("CBE 파일 조회 중 오류 발생:", error) + return { + files: [], + error: "CBE 파일을 가져오는 중 오류가 발생했습니다." + } + } +} + +export async function getAllCBE(input: GetCBESchema) { + return unstable_cache( + async () => { + // [1] 페이징 + const offset = ((input.page ?? 1) - 1) * (input.perPage ?? 10); + const limit = input.perPage ?? 10; + + // [2] 고급 필터 + const advancedWhere = filterColumns({ + table: vendorResponseCBEView, + filters: input.filters ?? [], + joinOperator: input.joinOperator ?? "and", + }); + + // [3] 글로벌 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + sql`${vendorResponseCBEView.vendorName} ILIKE ${s}`, + sql`${vendorResponseCBEView.vendorCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.rfqCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectCode} ILIKE ${s}`, + sql`${vendorResponseCBEView.projectName} ILIKE ${s}`, + sql`${vendorResponseCBEView.totalPrice}::text ILIKE ${s}` + ); + } + + // [4] DECLINED 상태 제외 (거절된 업체는 표시하지 않음) + const notDeclined = ne(vendorResponseCBEView.responseStatus, "DECLINED"); + + // [5] rfqType 필터 추가 + const rfqTypeFilter = input.rfqType ? eq(vendorResponseCBEView.rfqType, input.rfqType) : undefined; + + // [6] 최종 where 조건 + const finalWhere = and( + notDeclined, + advancedWhere ?? undefined, + globalWhere ?? undefined, + rfqTypeFilter // 새로 추가된 rfqType 필터 + ); + + // [7] 정렬 + const orderBy = input.sort?.length + ? input.sort.map((s) => { + // vendorResponseCBEView 컬럼 중 정렬 대상이 되는 것만 매핑 + const col = (vendorResponseCBEView as any)[s.id]; + return s.desc ? desc(col) : asc(col); + }) + : [desc(vendorResponseCBEView.rfqId), asc(vendorResponseCBEView.vendorName)]; // 기본 정렬은 최신 RFQ 먼저, 그 다음 벤더명 + + // [8] 메인 SELECT + const [rows, total] = await db.transaction(async (tx) => { + const data = await tx + .select({ + // 기본 식별 정보 + responseId: vendorResponseCBEView.responseId, + vendorId: vendorResponseCBEView.vendorId, + rfqId: vendorResponseCBEView.rfqId, + + // 협력업체 정보 + vendorName: vendorResponseCBEView.vendorName, + vendorCode: vendorResponseCBEView.vendorCode, + vendorStatus: vendorResponseCBEView.vendorStatus, + + // RFQ 정보 + rfqCode: vendorResponseCBEView.rfqCode, + rfqDescription: vendorResponseCBEView.rfqDescription, + rfqDueDate: vendorResponseCBEView.rfqDueDate, + rfqStatus: vendorResponseCBEView.rfqStatus, + rfqType: vendorResponseCBEView.rfqType, + + // 프로젝트 정보 + projectId: vendorResponseCBEView.projectId, + projectCode: vendorResponseCBEView.projectCode, + projectName: vendorResponseCBEView.projectName, + + // 응답 상태 정보 + responseStatus: vendorResponseCBEView.responseStatus, + responseNotes: vendorResponseCBEView.notes, + respondedAt: vendorResponseCBEView.respondedAt, + respondedBy: vendorResponseCBEView.respondedBy, + + // 상업 응답 정보 + commercialResponseId: vendorResponseCBEView.commercialResponseId, + commercialResponseStatus: vendorResponseCBEView.commercialResponseStatus, + totalPrice: vendorResponseCBEView.totalPrice, + currency: vendorResponseCBEView.currency, + paymentTerms: vendorResponseCBEView.paymentTerms, + incoterms: vendorResponseCBEView.incoterms, + deliveryPeriod: vendorResponseCBEView.deliveryPeriod, + warrantyPeriod: vendorResponseCBEView.warrantyPeriod, + validityPeriod: vendorResponseCBEView.validityPeriod, + commercialNotes: vendorResponseCBEView.commercialNotes, + + // 첨부파일 카운트 + attachmentCount: vendorResponseCBEView.attachmentCount, + commercialAttachmentCount: vendorResponseCBEView.commercialAttachmentCount, + technicalAttachmentCount: vendorResponseCBEView.technicalAttachmentCount, + }) + .from(vendorResponseCBEView) + .where(finalWhere) + .orderBy(...orderBy) + .offset(offset) + .limit(limit); + + const [{ count }] = await tx + .select({ count: sql<number>`count(*)`.as("count") }) + .from(vendorResponseCBEView) + .where(finalWhere); + + return [data, Number(count)]; + }); + + if (!rows.length) { + return { data: [], pageCount: 0, total: 0 }; + } + + // [9] 고유한 rfqIds와 vendorIds 추출 - null 필터링 + const distinctVendorIds = [...new Set(rows.map((r) => r.vendorId).filter(Boolean))] as number[]; + const distinctRfqIds = [...new Set(rows.map((r) => r.rfqId).filter(Boolean))] as number[]; + const distinctResponseIds = [...new Set(rows.map((r) => r.responseId).filter(Boolean))] as number[]; + const distinctCommercialResponseIds = [...new Set(rows.filter(r => r.commercialResponseId).map((r) => r.commercialResponseId!))]; + + // [10] CBE 평가 관련 코멘트 조회 + const commentsConditions = [isNotNull(rfqComments.cbeId)]; + + // 배열이 비어있지 않을 때만 조건 추가 + if (distinctRfqIds.length > 0) { + commentsConditions.push(inArray(rfqComments.rfqId, distinctRfqIds)); + } + + if (distinctVendorIds.length > 0) { + commentsConditions.push(inArray(rfqComments.vendorId, distinctVendorIds)); + } + + const commentsAll = await db + .select({ + id: rfqComments.id, + commentText: rfqComments.commentText, + vendorId: rfqComments.vendorId, + rfqId: rfqComments.rfqId, + cbeId: rfqComments.cbeId, + createdAt: rfqComments.createdAt, + commentedBy: rfqComments.commentedBy, + }) + .from(rfqComments) + .innerJoin( + vendorResponses, + eq(vendorResponses.id, rfqComments.cbeId) + ) + .where(and(...commentsConditions)); + + // [11] 복합 키(rfqId-vendorId)별 코멘트 그룹화 + const commentsByCompositeKey = new Map<string, any[]>(); + for (const comment of commentsAll) { + if (!comment.rfqId || !comment.vendorId) continue; + + const compositeKey = `${comment.rfqId}-${comment.vendorId}`; + if (!commentsByCompositeKey.has(compositeKey)) { + commentsByCompositeKey.set(compositeKey, []); + } + commentsByCompositeKey.get(compositeKey)!.push({ + id: comment.id, + commentText: comment.commentText, + vendorId: comment.vendorId, + cbeId: comment.cbeId, + createdAt: comment.createdAt, + commentedBy: comment.commentedBy, + }); + } + + // [12] 첨부 파일 조회 - 일반 응답 첨부파일 + const responseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + responseId: vendorResponseAttachments.responseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.responseId, distinctResponseIds), + isNotNull(vendorResponseAttachments.responseId) + ) + ); + + // [13] 첨부 파일 조회 - 상업 응답 첨부파일 + const commercialResponseAttachments = await db + .select({ + id: vendorResponseAttachments.id, + fileName: vendorResponseAttachments.fileName, + filePath: vendorResponseAttachments.filePath, + commercialResponseId: vendorResponseAttachments.commercialResponseId, + fileType: vendorResponseAttachments.fileType, + attachmentType: vendorResponseAttachments.attachmentType, + description: vendorResponseAttachments.description, + uploadedAt: vendorResponseAttachments.uploadedAt, + uploadedBy: vendorResponseAttachments.uploadedBy, + }) + .from(vendorResponseAttachments) + .where( + and( + inArray(vendorResponseAttachments.commercialResponseId, distinctCommercialResponseIds), + isNotNull(vendorResponseAttachments.commercialResponseId) + ) + ); + + // [14] 첨부파일 그룹화 + // responseId별 첨부파일 맵 생성 + const filesByResponseId = new Map<number, any[]>(); + for (const file of responseAttachments) { + const responseId = file.responseId!; + if (!filesByResponseId.has(responseId)) { + filesByResponseId.set(responseId, []); + } + filesByResponseId.get(responseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'response' + }); + } + + // commercialResponseId별 첨부파일 맵 생성 + const filesByCommercialResponseId = new Map<number, any[]>(); + for (const file of commercialResponseAttachments) { + const commercialResponseId = file.commercialResponseId!; + if (!filesByCommercialResponseId.has(commercialResponseId)) { + filesByCommercialResponseId.set(commercialResponseId, []); + } + filesByCommercialResponseId.get(commercialResponseId)!.push({ + id: file.id, + fileName: file.fileName, + filePath: file.filePath, + fileType: file.fileType, + attachmentType: file.attachmentType, + description: file.description, + uploadedAt: file.uploadedAt, + uploadedBy: file.uploadedBy, + attachmentSource: 'commercial' + }); + } + + // [15] 복합 키(rfqId-vendorId)별 첨부파일 맵 생성 + const filesByCompositeKey = new Map<string, any[]>(); + + // responseId -> rfqId-vendorId 매핑 생성 + const responseIdToCompositeKey = new Map<number, string>(); + for (const row of rows) { + if (row.responseId) { + responseIdToCompositeKey.set(row.responseId, `${row.rfqId}-${row.vendorId}`); + } + if (row.commercialResponseId) { + responseIdToCompositeKey.set(row.commercialResponseId, `${row.rfqId}-${row.vendorId}`); + } + } + + // responseId별 첨부파일을 복합 키별로 그룹화 + for (const [responseId, files] of filesByResponseId.entries()) { + const compositeKey = responseIdToCompositeKey.get(responseId); + if (compositeKey) { + if (!filesByCompositeKey.has(compositeKey)) { + filesByCompositeKey.set(compositeKey, []); + } + filesByCompositeKey.get(compositeKey)!.push(...files); + } + } + + // commercialResponseId별 첨부파일을 복합 키별로 그룹화 + for (const [commercialResponseId, files] of filesByCommercialResponseId.entries()) { + const compositeKey = responseIdToCompositeKey.get(commercialResponseId); + if (compositeKey) { + if (!filesByCompositeKey.has(compositeKey)) { + filesByCompositeKey.set(compositeKey, []); + } + filesByCompositeKey.get(compositeKey)!.push(...files); + } + } + + // [16] 최종 데이터 병합 + const final = rows.map((row) => { + const compositeKey = `${row.rfqId}-${row.vendorId}`; + + return { + ...row, + rfqDueDate: row.rfqDueDate ? new Date(row.rfqDueDate) : null, + respondedAt: row.respondedAt ? new Date(row.respondedAt) : null, + comments: commentsByCompositeKey.get(compositeKey) || [], + files: filesByCompositeKey.get(compositeKey) || [], + }; + }); + + const pageCount = Math.ceil(total / limit); + return { + data: final, + pageCount, + total + }; + }, + // 캐싱 키 & 옵션 + [`all-cbe-vendors-${JSON.stringify(input)}`], + { + revalidate: 3600, + tags: ["all-cbe-vendors"], + } + )(); }
\ No newline at end of file diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx index 41055608..9d4d7cf0 100644 --- a/lib/rfqs/table/add-rfq-dialog.tsx +++ b/lib/rfqs/table/add-rfq-dialog.tsx @@ -16,6 +16,7 @@ import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service" import { ProjectSelector } from "@/components/ProjectSelector" import { type Project } from "../service" import { ParentRfqSelector } from "./ParentRfqSelector" +import { EstimateProjectSelector } from "@/components/BidProjectSelector" // 부모 RFQ 정보 타입 정의 interface ParentRfq { @@ -43,18 +44,13 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) // Get the user ID safely, ensuring it's a valid number const userId = React.useMemo(() => { const id = session?.user?.id ? Number(session.user.id) : null; - - // Debug logging - remove in production - console.log("Session status:", status); - console.log("Session data:", session); - console.log("User ID:", id); - + return id; }, [session, status]); // RfqType에 따른 타이틀 생성 const getTitle = () => { - switch(rfqType) { + switch (rfqType) { case RfqType.PURCHASE: return "Purchase RFQ"; case RfqType.BUDGETARY: @@ -68,7 +64,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) // RfqType 설명 가져오기 const getTypeDescription = () => { - switch(rfqType) { + switch (rfqType) { case RfqType.PURCHASE: return "실제 구매 발주 전에 가격을 요청"; case RfqType.BUDGETARY: @@ -111,12 +107,12 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) try { // 서버 액션 호출 const result = await generateNextRfqCode(rfqType); - + if (result.error) { toast.error(`RFQ 코드 생성 실패: ${result.error}`); return; } - + // 생성된 코드를 폼에 설정 form.setValue("rfqCode", result.code); } catch (error) { @@ -126,14 +122,14 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) setIsLoadingRfqCode(false); } }; - + generateRfqCode(); } }, [open, rfqType, form]); // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정 const getParentRfqTypes = (): RfqType[] => { - switch(rfqType) { + switch (rfqType) { case RfqType.PURCHASE: // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음 return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]; @@ -153,13 +149,13 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) try { // 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기 const parentTypes = getParentRfqTypes(); - + // 부모 RFQ 타입이 있을 때만 API 호출 if (parentTypes.length > 0) { const result = await getBudgetaryRfqs({ rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요 }); - + if ('rfqs' in result) { setParentRfqs(result.rfqs as unknown as ParentRfq[]); } else if ('error' in result) { @@ -186,6 +182,14 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) form.setValue("projectId", project.id); }; + const handleBidProjectSelect = (project: Project | null) => { + if (project === null) { + return; + } + + form.setValue("bidProjectId", project.id); + }; + // 부모 RFQ 선택 처리 const handleParentRfqSelect = (rfq: ParentRfq | null) => { setSelectedParentRfq(rfq); @@ -212,7 +216,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) toast.error(`에러: ${result.error}`); return; } - + toast.success("RFQ가 성공적으로 생성되었습니다."); form.reset(); setSelectedParentRfq(null); @@ -234,7 +238,8 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) // 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정 const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY; - + const shouldShowEstimateSelector = rfqType === RfqType.BUDGETARY; + // 부모 RFQ 선택기 레이블 및 설명 가져오기 const getParentRfqSelectorLabel = () => { if (rfqType === RfqType.PURCHASE) { @@ -294,11 +299,18 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) <FormItem> <FormLabel>Project</FormLabel> <FormControl> - <ProjectSelector - selectedProjectId={field.value} - onProjectSelect={handleProjectSelect} - placeholder="프로젝트 선택..." - /> + + {shouldShowEstimateSelector ? + <EstimateProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleBidProjectSelect} + placeholder="견적 프로젝트 선택..." + /> : + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + />} </FormControl> <FormMessage /> </FormItem> @@ -317,11 +329,11 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) <ParentRfqSelector selectedRfqId={field.value as number | undefined} onRfqSelect={handleParentRfqSelect} - rfqType={rfqType} + rfqType={rfqType} parentRfqTypes={getParentRfqTypes()} placeholder={ - rfqType === RfqType.PURCHASE - ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." + rfqType === RfqType.PURCHASE + ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..." : "BUDGETARY RFQ 선택..." } /> @@ -344,9 +356,9 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) <FormLabel>RFQ Code</FormLabel> <FormControl> <div className="flex"> - <Input - placeholder="자동으로 생성 중..." - {...field} + <Input + placeholder="자동으로 생성 중..." + {...field} disabled={true} className="bg-muted" /> @@ -416,7 +428,7 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) disabled className="capitalize" {...field} - onChange={() => {}} // Prevent changes + onChange={() => { }} // Prevent changes /> </FormControl> <FormMessage /> @@ -433,8 +445,8 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) > Cancel </Button> - <Button - type="submit" + <Button + type="submit" disabled={form.formState.isSubmitting || status !== "authenticated"} > Create diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx index e4ff47d8..287f1d53 100644 --- a/lib/rfqs/table/rfqs-table.tsx +++ b/lib/rfqs/table/rfqs-table.tsx @@ -216,7 +216,7 @@ export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTablePro <div style={{ maxWidth: '100vw' }}> <DataTable table={table} - floatingBar={<RfqsTableFloatingBar table={table} />} + // floatingBar={<RfqsTableFloatingBar table={table} />} > <DataTableAdvancedToolbar table={table} diff --git a/lib/rfqs/tbe-table/comments-sheet.tsx b/lib/rfqs/tbe-table/comments-sheet.tsx index bea1fc8e..6efd631f 100644 --- a/lib/rfqs/tbe-table/comments-sheet.tsx +++ b/lib/rfqs/tbe-table/comments-sheet.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { useForm, useFieldArray } from "react-hook-form" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Download, X } from "lucide-react" +import { Download, X, Loader2 } from "lucide-react" import prettyBytes from "pretty-bytes" import { toast } from "sonner" @@ -26,41 +26,34 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" -import { - Textarea, -} from "@/components/ui/textarea" - +import { Textarea } from "@/components/ui/textarea" import { Dropzone, DropzoneZone, DropzoneUploadIcon, DropzoneTitle, DropzoneDescription, - DropzoneInput + DropzoneInput, } from "@/components/ui/dropzone" - import { Table, TableHeader, TableRow, TableHead, TableBody, - TableCell + TableCell, } from "@/components/ui/table" -// DB 스키마에서 필요한 타입들을 가져온다고 가정 -// (실제 프로젝트에 맞춰 import를 수정하세요.) -import { RfqWithAll } from "@/db/schema/rfq" import { createRfqCommentWithAttachments } from "../service" import { formatDate } from "@/lib/utils" -// 코멘트 + 첨부파일 구조 (단순 예시) -// 실제 DB 스키마에 맞춰 조정 + export interface TbeComment { id: number commentText: string commentedBy?: number - createdAt?: string | Date + commentedByEmail?: string + createdAt?: Date attachments?: { id: number fileName: string @@ -68,23 +61,21 @@ export interface TbeComment { }[] } +// 1) props 정의 interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - /** 코멘트를 작성할 RFQ 정보 */ - /** 이미 존재하는 모든 코멘트 목록 (서버에서 불러와 주입) */ initialComments?: TbeComment[] - - /** 사용자(작성자) ID (로그인 세션 등에서 가져옴) */ currentUserId: number - rfqId:number - vendorId:number - /** 댓글 저장 후 갱신용 콜백 (옵션) */ + rfqId: number + tbeId: number + vendorId: number onCommentsUpdated?: (comments: TbeComment[]) => void + isLoading?: boolean // New prop } -// 새 코멘트 작성 폼 스키마 +// 2) 폼 스키마 const commentFormSchema = z.object({ commentText: z.string().min(1, "댓글을 입력하세요."), - newFiles: z.array(z.any()).optional() // File[] + newFiles: z.array(z.any()).optional(), // File[] }) type CommentFormValues = z.infer<typeof commentFormSchema> @@ -95,40 +86,48 @@ export function CommentSheet({ vendorId, initialComments = [], currentUserId, + tbeId, onCommentsUpdated, + isLoading = false, // Default to false ...props }: CommentSheetProps) { + console.log("tbeId", tbeId) + const [comments, setComments] = React.useState<TbeComment[]>(initialComments) const [isPending, startTransition] = React.useTransition() React.useEffect(() => { setComments(initialComments) }, [initialComments]) - - // RHF 세팅 const form = useForm<CommentFormValues>({ resolver: zodResolver(commentFormSchema), defaultValues: { commentText: "", - newFiles: [] - } + newFiles: [], + }, }) - // formFieldArray 예시 (파일 목록) const { fields: newFileFields, append, remove } = useFieldArray({ control: form.control, - name: "newFiles" + name: "newFiles", }) - // 1) 기존 코멘트 + 첨부 보여주기 - // 간단히 테이블 하나로 표현 - // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 + // (A) 기존 코멘트 렌더링 function renderExistingComments() { + + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + if (comments.length === 0) { return <p className="text-sm text-muted-foreground">No comments yet</p> } - return ( <Table> <TableHeader> @@ -144,16 +143,15 @@ export function CommentSheet({ <TableRow key={c.id}> <TableCell>{c.commentText}</TableCell> <TableCell> - {/* 첨부파일 표시 */} - {(!c.attachments || c.attachments.length === 0) && ( + {!c.attachments?.length && ( <span className="text-sm text-muted-foreground">No files</span> )} - {c.attachments && c.attachments.length > 0 && ( + {c.attachments?.length && ( <div className="flex flex-col gap-1"> {c.attachments.map((att) => ( <div key={att.id} className="flex items-center gap-2"> <a - href={att.filePath} + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} download target="_blank" rel="noreferrer" @@ -167,10 +165,8 @@ export function CommentSheet({ </div> )} </TableCell> - <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> - <TableCell> - {c.commentedBy ?? "-"} - </TableCell> + <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> </TableRow> ))} </TableBody> @@ -178,28 +174,28 @@ export function CommentSheet({ ) } - // 2) 새 파일 Drop + // (B) 파일 드롭 function handleDropAccepted(files: File[]) { - // 드롭된 File[]을 RHF field array에 추가 - const toAppend = files.map((f) => f) - append(toAppend) + append(files) } - - // 3) 저장(Submit) + // (C) Submit async function onSubmit(data: CommentFormValues) { - if (!rfqId) return startTransition(async () => { try { - // 서버 액션 호출 + console.log("rfqId", rfqId) + console.log("vendorId", vendorId) + console.log("tbeId", tbeId) + console.log("currentUserId", currentUserId) const res = await createRfqCommentWithAttachments({ - rfqId: rfqId, - vendorId: vendorId, // 필요시 세팅 + rfqId, + vendorId, commentText: data.commentText, commentedBy: currentUserId, - evaluationId: null, // 필요시 세팅 - files: data.newFiles + evaluationId: tbeId, + cbeId: null, + files: data.newFiles, }) if (!res.ok) { @@ -208,23 +204,22 @@ export function CommentSheet({ toast.success("Comment created") - // 새 코멘트를 다시 불러오거나, - // 여기서는 임시로 "새로운 코멘트가 추가됐다" 라고 가정하여 클라이언트에서 상태 업데이트 + // 임시로 새 코멘트 추가 const newComment: TbeComment = { - id: res.commentId, // 서버에서 반환된 commentId + id: res.commentId, // 서버 응답 commentText: data.commentText, commentedBy: currentUserId, - createdAt: new Date().toISOString(), - attachments: (data.newFiles?.map((f, idx) => ({ - id: Math.random() * 100000, - fileName: f.name, - filePath: "/uploads/" + f.name, - })) || []) + createdAt: res.createdAt, + attachments: + data.newFiles?.map((f) => ({ + id: Math.floor(Math.random() * 1e6), + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || [], } setComments((prev) => [...prev, newComment]) onCommentsUpdated?.([...comments, newComment]) - // 폼 리셋 form.reset() } catch (err: any) { console.error(err) @@ -243,12 +238,8 @@ export function CommentSheet({ </SheetDescription> </SheetHeader> - {/* 기존 코멘트 목록 */} - <div className="max-h-[300px] overflow-y-auto"> - {renderExistingComments()} - </div> + <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> - {/* 새 코멘트 작성 Form */} <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> <FormField @@ -258,17 +249,13 @@ export function CommentSheet({ <FormItem> <FormLabel>New Comment</FormLabel> <FormControl> - <Textarea - placeholder="Enter your comment..." - {...field} - /> + <Textarea placeholder="Enter your comment..." {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> - {/* Dropzone (파일 첨부) */} <Dropzone maxSize={MAX_FILE_SIZE} onDropAccepted={handleDropAccepted} @@ -292,15 +279,19 @@ export function CommentSheet({ )} </Dropzone> - {/* 선택된 파일 목록 */} {newFileFields.length > 0 && ( <div className="flex flex-col gap-2"> {newFileFields.map((field, idx) => { const file = form.getValues(`newFiles.${idx}`) if (!file) return null return ( - <div key={field.id} className="flex items-center justify-between border rounded p-2"> - <span className="text-sm">{file.name} ({prettyBytes(file.size)})</span> + <div + key={field.id} + className="flex items-center justify-between border rounded p-2" + > + <span className="text-sm"> + {file.name} ({prettyBytes(file.size)}) + </span> <Button variant="ghost" size="icon" @@ -322,7 +313,7 @@ export function CommentSheet({ </Button> </SheetClose> <Button disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} Save </Button> </SheetFooter> diff --git a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx index e38e0ede..935d2bf3 100644 --- a/lib/rfqs/tbe-table/invite-vendors-dialog.tsx +++ b/lib/rfqs/tbe-table/invite-vendors-dialog.tsx @@ -32,6 +32,9 @@ import { Input } from "@/components/ui/input" import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" import { inviteTbeVendorsAction } from "../service" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { Label } from "@/components/ui/label" interface InviteVendorsDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -94,6 +97,23 @@ export function InviteVendorsDialog({ // 파일 선택 UI const fileInput = ( +<> + <div className="space-y-2"> + <Label>선택된 협력업체 ({vendors.length})</Label> + <ScrollArea className="h-20 border rounded-md p-2"> + <div className="flex flex-wrap gap-2"> + {vendors.map((vendor, index) => ( + <Badge key={index} variant="secondary" className="py-1"> + {vendor.vendorName || `협력업체 #${vendor.vendorCode}`} + </Badge> + ))} + </div> + </ScrollArea> + <p className="text-[0.8rem] font-medium text-muted-foreground"> + 선택된 모든 협력업체의 등록된 연락처에게 TBE 평가 알림이 전송됩니다. + </p> + </div> + <div className="mb-4"> <label className="mb-2 block font-medium">TBE Sheets</label> <Input @@ -104,6 +124,7 @@ export function InviteVendorsDialog({ }} /> </div> + </> ) // Desktop Dialog @@ -114,17 +135,15 @@ export function InviteVendorsDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm"> <Send className="mr-2 size-4" aria-hidden="true" /> - Invite ({vendors.length}) + TBE 평가 생성 ({vendors.length}) </Button> </DialogTrigger> ) : null} <DialogContent> <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogTitle>TBE 평가 시트 전송</DialogTitle> <DialogDescription> - This action cannot be undone. This will permanently invite{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. + 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. </DialogDescription> </DialogHeader> @@ -169,12 +188,10 @@ export function InviteVendorsDialog({ ) : null} <DrawerContent> <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> - <DrawerDescription> - This action cannot be undone. This will permanently invite{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}. - </DrawerDescription> + <DialogTitle>TBE 평가 시트 전송</DialogTitle> + <DialogDescription> + 선택한 {vendors.length}개 협력업체에 대한 기술 평가 시트와 알림을 전송합니다. 파일 첨부가 필수이므로 파일을 첨부해야지 버튼이 활성화됩니다. + </DialogDescription> </DrawerHeader> {/* 파일 첨부 */} diff --git a/lib/rfqs/tbe-table/tbe-result-dialog.tsx b/lib/rfqs/tbe-table/tbe-result-dialog.tsx new file mode 100644 index 00000000..8400ecac --- /dev/null +++ b/lib/rfqs/tbe-table/tbe-result-dialog.tsx @@ -0,0 +1,208 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { getErrorMessage } from "@/lib/handle-error" +import { saveTbeResult } from "../service" + +// Define the props for the TbeResultDialog component +interface TbeResultDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + tbe: VendorWithTbeFields | null + onRefresh?: () => void +} + +// Define TBE result options +const TBE_RESULT_OPTIONS = [ + { value: "pass", label: "Pass", badgeVariant: "default" }, + { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" }, + { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" }, +] as const + +type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"] + +export function TbeResultDialog({ + open, + onOpenChange, + tbe, + onRefresh, +}: TbeResultDialogProps) { + // Initialize state for form inputs + const [result, setResult] = React.useState<TbeResultOption | "">("") + const [note, setNote] = React.useState("") + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // Update form values when the tbe prop changes + React.useEffect(() => { + if (tbe) { + setResult((tbe.tbeResult as TbeResultOption) || "") + setNote(tbe.tbeNote || "") + } + }, [tbe]) + + // Reset form when dialog closes + React.useEffect(() => { + if (!open) { + // Small delay to avoid visual glitches when dialog is closing + const timer = setTimeout(() => { + if (!tbe) { + setResult("") + setNote("") + } + }, 300) + return () => clearTimeout(timer) + } + }, [open, tbe]) + + // Handle form submission with server action + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!tbe || !result) return + + setIsSubmitting(true) + + try { + // Call the server action to save the TBE result + const response = await saveTbeResult({ + id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table + vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table + result: result, // The selected evaluation result + notes: note, // The evaluation notes + }) + + if (!response.success) { + throw new Error(response.message || "Failed to save TBE result") + } + + // Show success toast + toast.success("TBE result saved successfully") + + // Close the dialog + onOpenChange(false) + + // Refresh the data if refresh callback is provided + if (onRefresh) { + onRefresh() + } + } catch (error) { + // Show error toast + toast.error(`Failed to save: ${getErrorMessage(error)}`) + } finally { + setIsSubmitting(false) + } + } + + // Find the selected result option + const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle className="text-xl font-semibold"> + {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"} + </DialogTitle> + {tbe && ( + <DialogDescription className="text-sm text-muted-foreground mt-1"> + <div className="flex flex-col gap-1"> + <span> + <strong>Vendor:</strong> {tbe.vendorName} + </span> + <span> + <strong>RFQ Code:</strong> {tbe.rfqCode} + </span> + {tbe.email && ( + <span> + <strong>Email:</strong> {tbe.email} + </span> + )} + </div> + </DialogDescription> + )} + </DialogHeader> + + <form onSubmit={handleSubmit} className="space-y-6 py-2"> + <div className="space-y-2"> + <Label htmlFor="tbe-result" className="text-sm font-medium"> + Evaluation Result + </Label> + <Select + value={result} + onValueChange={(value) => setResult(value as TbeResultOption)} + required + > + <SelectTrigger id="tbe-result" className="w-full"> + <SelectValue placeholder="Select a result" /> + </SelectTrigger> + <SelectContent> + {TBE_RESULT_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <div className="flex items-center"> + <Badge variant={option.badgeVariant as any} className="mr-2"> + {option.label} + </Badge> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <Label htmlFor="tbe-note" className="text-sm font-medium"> + Evaluation Note + </Label> + <Textarea + id="tbe-note" + placeholder="Enter evaluation notes..." + value={note} + onChange={(e) => setNote(e.target.value)} + className="min-h-[120px] resize-y" + /> + </div> + + <DialogFooter className="gap-2 sm:gap-0"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button + type="submit" + disabled={!result || isSubmitting} + className="min-w-[100px]" + > + {isSubmitting ? "Saving..." : "Save"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/tbe-table-columns.tsx b/lib/rfqs/tbe-table/tbe-table-columns.tsx index 0e9b7064..e8566831 100644 --- a/lib/rfqs/tbe-table/tbe-table-columns.tsx +++ b/lib/rfqs/tbe-table/tbe-table-columns.tsx @@ -11,21 +11,11 @@ import { formatDate } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu" + import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { useRouter } from "next/navigation" import { - VendorTbeColumnConfig, vendorTbeColumnsConfig, VendorWithTbeFields, } from "@/config/vendorTbeColumnsConfig" @@ -39,6 +29,8 @@ interface GetColumnsProps { router: NextRouter openCommentSheet: (vendorId: number) => void openFilesDialog: (tbeId:number , vendorId: number) => void + openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처 + } /** @@ -46,9 +38,9 @@ interface GetColumnsProps { */ export function getColumns({ setRowAction, - router, openCommentSheet, - openFilesDialog + openFilesDialog, + openVendorContactsDialog }: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { // ---------------------------------------------------------------- // 1) Select 컬럼 (체크박스) @@ -107,6 +99,85 @@ export function getColumns({ // 1) 필드값 가져오기 const val = getValue() + if (cfg.id === "vendorName") { + const vendor = row.original; + const vendorId = vendor.vendorId; + + // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 + const handleVendorNameClick = () => { + if (vendorId) { + openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 + } else { + toast.error("협력업체 ID를 찾을 수 없습니다."); + } + }; + + return ( + <Button + variant="link" + className="p-0 h-auto text-left font-normal justify-start hover:underline" + onClick={handleVendorNameClick} + > + {val as string} + </Button> + ); + } + + if (cfg.id === "tbeResult") { + const vendor = row.original; + const tbeResult = vendor.tbeResult; + const filesCount = vendor.files?.length ?? 0; + + // Only show button or link if there are files + if (filesCount > 0) { + // Function to handle clicking on the result + const handleTbeResultClick = () => { + setRowAction({ row, type: "tbeResult" }); + }; + + if (!tbeResult) { + // No result yet, but files exist - show "결과 입력" button + return ( + <Button + variant="outline" + size="sm" + onClick={handleTbeResultClick} + > + 결과 입력 + </Button> + ); + } else { + // Result exists - show as a hyperlink + let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline"; + + // Set badge variant based on result + if (tbeResult === "pass") { + badgeVariant = "default"; + } else if (tbeResult === "non-pass") { + badgeVariant = "destructive"; + } else if (tbeResult === "conditional pass") { + badgeVariant = "secondary"; + } + + return ( + <Button + variant="link" + className="p-0 h-auto underline" + onClick={handleTbeResultClick} + > + <Badge variant={badgeVariant}> + {tbeResult} + </Badge> + </Button> + ); + } + } + + // No files available, return empty cell + return null; + } + + if (cfg.id === "vendorStatus") { const statusVal = row.original.vendorStatus if (!statusVal) return null @@ -131,6 +202,8 @@ export function getColumns({ ) } + + // 예) TBE Updated (날짜) if (cfg.id === "tbeUpdated") { const dateVal = val as Date | undefined diff --git a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx index 6a336135..a8f8ea82 100644 --- a/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx +++ b/lib/rfqs/tbe-table/tbe-table-toolbar-actions.tsx @@ -28,18 +28,25 @@ export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarA fileInputRef.current?.click() } + const invitationPossibeVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.technicalResponseStatus === null); + }, [table.getFilteredSelectedRowModel().rows]); + return ( <div className="flex items-center gap-2"> - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( - <InviteVendorsDialog - vendors={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} + {invitationPossibeVendors.length > 0 && + ( + <InviteVendorsDialog + vendors={invitationPossibeVendors} rfqId = {rfqId} onSuccess={() => table.toggleAllRowsSelected(false)} - /> - ) : null} - + /> + ) + } <Button variant="outline" diff --git a/lib/rfqs/tbe-table/tbe-table.tsx b/lib/rfqs/tbe-table/tbe-table.tsx index 41eff0dc..0add8927 100644 --- a/lib/rfqs/tbe-table/tbe-table.tsx +++ b/lib/rfqs/tbe-table/tbe-table.tsx @@ -21,6 +21,9 @@ import { InviteVendorsDialog } from "./invite-vendors-dialog" import { CommentSheet, TbeComment } from "./comments-sheet" import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" import { TBEFileDialog } from "./file-dialog" +import { TbeResultDialog } from "./tbe-result-dialog" +import { VendorContactsDialog } from "./vendor-contact-dialog" +import { useSession } from "next-auth/react" // Next-auth session hook 추가 interface VendorsTableProps { promises: Promise< @@ -37,8 +40,11 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { // Suspense로 받아온 데이터 const [{ data, pageCount }] = React.use(promises) + console.log("data", data) + const { data: session } = useSession() // 세션 정보 가져오기 + + const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - console.log(data) const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithTbeFields> | null>(null) @@ -48,13 +54,12 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) - + const [isFileDialogOpen, setIsFileDialogOpen] = React.useState(false) const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) const [selectedTbeId, setSelectedTbeId] = React.useState<number | null>(null) - - console.log(selectedVendorId,"selectedVendorId") - console.log(rfqId,"rfqId") + const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null) // Add handleRefresh function const handleRefresh = React.useCallback(() => { @@ -73,11 +78,14 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { } }, [rowAction]) - async function openCommentSheet(vendorId: number) { + async function openCommentSheet(a: number) { setInitialComments([]) - + const comments = rowAction?.row.original.comments - + const rfqId = rowAction?.row.original.rfqId + const vendorId = rowAction?.row.original.vendorId + const tbeId = rowAction?.row.original.tbeId + console.log("original", rowAction?.row.original) if (comments && comments.length > 0) { const commentWithAttachments: TbeComment[] = await Promise.all( comments.map(async (c) => { @@ -85,7 +93,7 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { return { ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 + commentedBy: currentUserId, // DB나 API 응답에 있다고 가정 attachments, } }) @@ -93,8 +101,9 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { // 3) state에 저장 -> CommentSheet에서 initialComments로 사용 setInitialComments(commentWithAttachments) } - - setSelectedRfqIdForComments(vendorId) + setSelectedTbeId(tbeId ?? 0) + setSelectedVendorId(vendorId ?? 0) + setSelectedRfqIdForComments(rfqId ?? 0) setCommentSheetOpen(true) } @@ -103,11 +112,15 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { setSelectedVendorId(vendorId) setIsFileDialogOpen(true) } - + const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => { + setSelectedVendorId(vendorId) + setSelectedVendor(vendor) + setIsContactDialogOpen(true) + } // getColumns() 호출 시, router를 주입 const columns = React.useMemo( - () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog }), + () => getColumns({ setRowAction, router, openCommentSheet, openFilesDialog, openVendorContactsDialog }), [setRowAction, router] ) @@ -141,18 +154,20 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { enableAdvancedFilter: true, initialState: { sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["actions"] }, + columnPinning: { right: ["comments"] }, }, getRowId: (originalRow) => String(originalRow.id), shallow: false, clearOnDefault: true, }) + + return ( -<div style={{ maxWidth: '80vw' }}> + <div style={{ maxWidth: '80vw' }}> <DataTable table={table} - > + > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} @@ -169,11 +184,12 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { showTrigger={false} /> <CommentSheet - currentUserId={1} + currentUserId={currentUserId} open={commentSheetOpen} onOpenChange={setCommentSheetOpen} rfqId={rfqId} - vendorId={selectedRfqIdForComments ?? 0} + tbeId={selectedTbeId ?? 0} + vendorId={selectedVendorId ?? 0} initialComments={initialComments} /> @@ -185,6 +201,20 @@ export function TbeTable({ promises, rfqId }: VendorsTableProps) { rfqId={rfqId} // Use the prop directly instead of data[0]?.rfqId onRefresh={handleRefresh} /> + + <TbeResultDialog + open={rowAction?.type === "tbeResult"} + onOpenChange={() => setRowAction(null)} + tbe={rowAction?.row.original ?? null} + /> + + <VendorContactsDialog + isOpen={isContactDialogOpen} + onOpenChange={setIsContactDialogOpen} + vendorId={selectedVendorId} + vendor={selectedVendor} + /> + </div> ) }
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/vendor-contact-dialog.tsx b/lib/rfqs/tbe-table/vendor-contact-dialog.tsx new file mode 100644 index 00000000..3619fe77 --- /dev/null +++ b/lib/rfqs/tbe-table/vendor-contact-dialog.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { VendorContactsTable } from "./vendor-contact/vendor-contact-table" +import { Badge } from "@/components/ui/badge" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" + +interface VendorContactsDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null + vendor: VendorWithTbeFields | null +} + +export function VendorContactsDialog({ + isOpen, + onOpenChange, + vendorId, + vendor, +}: VendorContactsDialogProps) { + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> + <DialogHeader> + <div className="flex flex-col space-y-2"> + <DialogTitle>협력업체 연락처</DialogTitle> + {vendor && ( + <div className="flex flex-col space-y-1 mt-2"> + <div className="text-sm text-muted-foreground"> + <span className="font-medium text-foreground">{vendor.vendorName}</span> + {vendor.vendorCode && ( + <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> + )} + </div> + <div className="flex items-center"> + {vendor.vendorStatus && ( + <Badge variant="outline" className="mr-2"> + {vendor.vendorStatus} + </Badge> + )} + {vendor.rfqVendorStatus && ( + <Badge + variant={ + vendor.rfqVendorStatus === "INVITED" ? "default" : + vendor.rfqVendorStatus === "DECLINED" ? "destructive" : + vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline" + } + > + {vendor.rfqVendorStatus} + </Badge> + )} + </div> + </div> + )} + </div> + </DialogHeader> + {vendorId && ( + <div className="py-4"> + <VendorContactsTable vendorId={vendorId} /> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx new file mode 100644 index 00000000..fcd0c3fb --- /dev/null +++ b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table-column.tsx @@ -0,0 +1,70 @@ +"use client" +// Because columns rely on React state/hooks for row actions + +import * as React from "react" +import { ColumnDef, Row } from "@tanstack/react-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { VendorData } from "./vendor-contact-table" + + +/** getColumns: return array of ColumnDef for 'vendors' data */ +export function getColumns(): ColumnDef<VendorData>[] { + return [ + + // Vendor Name + { + accessorKey: "contactName", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Contact Name" /> + ), + cell: ({ row }) => row.getValue("contactName"), + }, + + // Vendor Code + { + accessorKey: "contactPosition", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Position" /> + ), + cell: ({ row }) => row.getValue("contactPosition"), + }, + + // Status + { + accessorKey: "contactEmail", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Email" /> + ), + cell: ({ row }) => row.getValue("contactEmail"), + }, + + // Country + { + accessorKey: "contactPhone", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Phone" /> + ), + cell: ({ row }) => row.getValue("contactPhone"), + }, + + // Created At + { + accessorKey: "createdAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + }, + + // Updated At + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + }, + ] +}
\ No newline at end of file diff --git a/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx new file mode 100644 index 00000000..c079da02 --- /dev/null +++ b/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table.tsx @@ -0,0 +1,89 @@ +'use client' + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { getColumns } from "./vendor-contact-table-column" +import { DataTableAdvancedFilterField } from "@/types/table" +import { Loader2 } from "lucide-react" +import { useToast } from "@/hooks/use-toast" +import { getVendorContactsByVendorId } from "../../service" + +export interface VendorData { + id: number + contactName: string + contactPosition: string | null + contactEmail: string + contactPhone: string | null + isPrimary: boolean | null + createdAt: Date + updatedAt: Date +} + +interface VendorContactsTableProps { + vendorId: number +} + +export function VendorContactsTable({ vendorId }: VendorContactsTableProps) { + const { toast } = useToast() + + const columns = React.useMemo( + () => getColumns(), + [] + ) + + const [vendorContacts, setVendorContacts] = React.useState<VendorData[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + React.useEffect(() => { + async function loadVendorContacts() { + setIsLoading(true) + try { + const result = await getVendorContactsByVendorId(vendorId) + if (result.success && result.data) { + // undefined 체크 추가 및 타입 캐스팅 + setVendorContacts(result.data as VendorData[]) + } else { + throw new Error(result.error || "Unknown error occurred") + } + } catch (error) { + console.error("협력업체 연락처 로드 오류:", error) + toast({ + title: "Error", + description: "Failed to load vendor contacts", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + loadVendorContacts() + }, [toast, vendorId]) + + const advancedFilterFields: DataTableAdvancedFilterField<VendorData>[] = [ + { id: "contactName", label: "Contact Name", type: "text" }, + { id: "contactPosition", label: "Posiotion", type: "text" }, + { id: "contactEmail", label: "Email", type: "text" }, + { id: "contactPhone", label: "Phone", type: "text" }, + + + ] + + // If loading, show a flex container that fills the parent and centers the spinner + if (isLoading) { + return ( + <div className="flex h-full w-full items-center justify-center"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) + } + + // Otherwise, show the table + return ( + <ClientDataTable + data={vendorContacts} + columns={columns} + advancedFilterFields={advancedFilterFields} + > + </ClientDataTable> + ) +}
\ No newline at end of file diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts index 9e9e96cc..59e9e362 100644 --- a/lib/rfqs/validations.ts +++ b/lib/rfqs/validations.ts @@ -2,18 +2,18 @@ import { createSearchParamsCache, parseAsArrayOf, parseAsInteger, parseAsString, - parseAsStringEnum, + parseAsStringEnum,parseAsBoolean } from "nuqs/server" import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Rfq, rfqs, RfqsView, VendorCbeView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq"; +import { Rfq, rfqs, RfqsView, VendorCbeView, VendorResponseCBEView, VendorRfqViewBase, VendorTbeView } from "@/db/schema/rfq"; import { Vendor, vendors } from "@/db/schema/vendors"; export const RfqType = { PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY", PURCHASE: "PURCHASE", - BUDGETARY: "BUDGETARY" + BUDGETARY: "c" } as const; export type RfqType = typeof RfqType[keyof typeof RfqType]; @@ -129,6 +129,7 @@ export const createRfqSchema = z.object({ rfqCode: z.string().min(3, "RFQ 코드는 최소 3글자 이상이어야 합니다"), description: z.string().optional(), projectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) + bidProjectId: z.number().nullable().optional(), // 프로젝트 ID (선택적) parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적) dueDate: z.date(), status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]), @@ -227,50 +228,70 @@ export const updateRfqVendorSchema = z.object({ export type UpdateRfqVendorSchema = z.infer<typeof updateRfqVendorSchema> - - export const searchParamsCBECache = createSearchParamsCache({ // 1) 공통 플래그 flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - + // 2) 페이지네이션 page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), - - // 3) 정렬 (Rfq 테이블) - // getSortingStateParser<Rfq>() → Rfq 테이블의 컬럼명에 맞춘 유효성 검사 - sort: getSortingStateParser<VendorCbeView>().withDefault([ - { id: "cbeUpdated", desc: true }, + + // 3) 정렬 (VendorResponseCBEView 테이블) + // getSortingStateParser<VendorResponseCBEView>() → CBE 테이블의 컬럼명에 맞춤 + sort: getSortingStateParser<VendorResponseCBEView>().withDefault([ + { id: "totalPrice", desc: true }, ]), - - // 4) 간단 검색 필드 + + // 4) 간단 검색 필드 - 기본 정보 vendorName: parseAsString.withDefault(""), vendorCode: parseAsString.withDefault(""), country: parseAsString.withDefault(""), email: parseAsString.withDefault(""), website: parseAsString.withDefault(""), - - cbeResult: parseAsString.withDefault(""), - cbeNote: parseAsString.withDefault(""), - cbeUpdated: parseAsString.withDefault(""), - rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), - - - totalCost: parseAsInteger.withDefault(0), + + // CBE 관련 필드 + commercialResponseId: parseAsString.withDefault(""), + totalPrice: parseAsString.withDefault(""), currency: parseAsString.withDefault(""), paymentTerms: parseAsString.withDefault(""), incoterms: parseAsString.withDefault(""), - deliverySchedule: parseAsString.withDefault(""), - - // 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED" - // rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리 + deliveryPeriod: parseAsString.withDefault(""), + warrantyPeriod: parseAsString.withDefault(""), + validityPeriod: parseAsString.withDefault(""), + + // RFQ 관련 필드 + rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"), + + // 응답 상태 + responseStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "REVIEWING", "RESPONDED"]).withDefault("REVIEWING"), + + // 5) 상태 (배열) - vendor 상태 vendorStatus: parseAsArrayOf(z.enum(vendors.status.enumValues)).withDefault([]), - + // 6) 고급 필터 (nuqs - filterColumns) filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), - + // 7) 글로벌 검색어 search: parseAsString.withDefault(""), + + // 8) 첨부파일 관련 필터 + hasAttachments: parseAsBoolean.withDefault(false), + + // 9) 날짜 범위 필터 + respondedAtRange: parseAsString.withDefault(""), + commercialUpdatedAtRange: parseAsString.withDefault(""), }) + export type GetCBESchema = Awaited<ReturnType<typeof searchParamsCBECache.parse>>; + + +export const createCbeEvaluationSchema = z.object({ + paymentTerms: z.string().min(1, "결제 조건을 입력하세요"), + incoterms: z.string().min(1, "Incoterms를 입력하세요"), + deliverySchedule: z.string().min(1, "배송 일정을 입력하세요"), + notes: z.string().optional(), +}) + +// 타입 추출 +export type CreateCbeEvaluationSchema = z.infer<typeof createCbeEvaluationSchema>
\ No newline at end of file diff --git a/lib/rfqs/vendor-table/comments-sheet.tsx b/lib/rfqs/vendor-table/comments-sheet.tsx index 3a2a9353..441fdcf1 100644 --- a/lib/rfqs/vendor-table/comments-sheet.tsx +++ b/lib/rfqs/vendor-table/comments-sheet.tsx @@ -53,7 +53,7 @@ export interface MatchedVendorComment { commentText: string commentedBy?: number commentedByEmail?: string - createdAt?: Date + createdAt?: Date attachments?: { id: number fileName: string @@ -90,8 +90,6 @@ export function CommentSheet({ ...props }: CommentSheetProps) { - console.log(initialComments) - const [comments, setComments] = React.useState<MatchedVendorComment[]>(initialComments) const [isPending, startTransition] = React.useTransition() @@ -138,7 +136,7 @@ export function CommentSheet({ </TableRow> </TableHeader> <TableBody> - {comments.map((c) => ( + {comments.map((c) => ( <TableRow key={c.id}> <TableCell>{c.commentText}</TableCell> <TableCell> @@ -150,7 +148,7 @@ export function CommentSheet({ {c.attachments.map((att) => ( <div key={att.id} className="flex items-center gap-2"> <a - href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} download target="_blank" rel="noreferrer" @@ -164,7 +162,7 @@ export function CommentSheet({ </div> )} </TableCell> - <TableCell> { c.createdAt ? formatDate(c.createdAt): "-"}</TableCell> + <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> <TableCell>{c.commentedByEmail ?? "-"}</TableCell> </TableRow> ))} diff --git a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx index c436eebd..e34a5052 100644 --- a/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx +++ b/lib/rfqs/vendor-table/vendor-list/vendor-list-table.tsx @@ -52,7 +52,7 @@ export function VendorsListTable({ rfqId }: VendorsListTableProps) { const allVendors = await getAllVendors() setVendors(allVendors) } catch (error) { - console.error("벤더 목록 로드 오류:", error) + console.error("협력업체 목록 로드 오류:", error) toast({ title: "Error", description: "Failed to load vendors", diff --git a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx index abb34f85..864d0f4b 100644 --- a/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx +++ b/lib/rfqs/vendor-table/vendors-table-toolbar-actions.tsx @@ -21,14 +21,14 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar // 선택된 모든 행 const selectedRows = table.getFilteredSelectedRowModel().rows - // 조건에 맞는 벤더만 필터링 + // 조건에 맞는 협력업체만 필터링 const eligibleVendors = React.useMemo(() => { return selectedRows .map(row => row.original) .filter(vendor => !vendor.rfqVendorStatus || vendor.rfqVendorStatus === "INVITED") }, [selectedRows]) - // 조건에 맞지 않는 벤더 수 + // 조건에 맞지 않는 협력업체 수 const ineligibleCount = selectedRows.length - eligibleVendors.length function handleImportClick() { @@ -36,17 +36,17 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar } function handleInviteClick() { - // 조건에 맞지 않는 벤더가 있다면 토스트 메시지 표시 + // 조건에 맞지 않는 협력업체가 있다면 토스트 메시지 표시 if (ineligibleCount > 0) { toast({ - title: "일부 벤더만 초대됩니다", + title: "일부 협력업체만 초대됩니다", description: `선택한 ${selectedRows.length}개 중 ${eligibleVendors.length}개만 초대 가능합니다. 나머지 ${ineligibleCount}개는 초대 불가능한 상태입니다.`, // variant: "warning", }) } } - // 다이얼로그 표시 여부 - 적합한 벤더가 1개 이상 있으면 표시 + // 다이얼로그 표시 여부 - 적합한 협력업체가 1개 이상 있으면 표시 const showInviteDialog = eligibleVendors.length > 0 return ( @@ -70,7 +70,7 @@ export function VendorsTableToolbarActions({ table, rfqId }: VendorsTableToolbar variant="default" size="sm" disabled={true} - title="선택된 벤더 중 초대 가능한 벤더가 없습니다" + title="선택된 협력업체 중 초대 가능한 협력업체가 없습니다" > 초대 불가 </Button> diff --git a/lib/rfqs/vendor-table/vendors-table.tsx b/lib/rfqs/vendor-table/vendors-table.tsx index ae9cba41..b2e4d5ad 100644 --- a/lib/rfqs/vendor-table/vendors-table.tsx +++ b/lib/rfqs/vendor-table/vendors-table.tsx @@ -74,17 +74,17 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr async function openCommentSheet(vendorId: number) { // Clear previous comments setInitialComments([]) - + // Start loading setIsLoadingComments(true) - + // Open the sheet immediately with loading state setSelectedVendorIdForComments(vendorId) setCommentSheetOpen(true) - + // (a) 현재 Row의 comments 불러옴 const comments = rowAction?.row.original.comments - + try { if (comments && comments.length > 0) { // (b) 각 comment마다 첨부파일 fetch @@ -107,7 +107,7 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr setIsLoadingComments(false) } } - + // 6) 컬럼 정의 (memo) const columns = React.useMemo( () => getColumns({ setRowAction, router, openCommentSheet }), @@ -164,10 +164,8 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr // 세션에서 userId 추출하고 숫자로 변환 const currentUserId = session?.user?.id ? parseInt(session.user.id, 10) : 0 - console.log(currentUserId,"currentUserId") - return ( - <div style={{ maxWidth: '80vw' }}> + <> <DataTable table={table} > @@ -205,6 +203,6 @@ export function MatchedVendorsTable({ promises, rfqId, rfqType }: VendorsTablePr rowAction.row.original.comments = updatedComments }} /> - </div> + </> ) }
\ No newline at end of file diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts new file mode 100644 index 00000000..b488bfad --- /dev/null +++ b/lib/sedp/get-form-tags.ts @@ -0,0 +1,380 @@ +// lib/sedp/get-tag.ts +import db from "@/db/db"; +import { + contractItems, + tags, + forms, + items, + tagTypeClassFormMappings, + projects, + tagTypes, + tagClasses, + formMetas, + formEntries +} from "@/db/schema"; +import { eq, and, like, inArray } from "drizzle-orm"; +import { getSEDPToken } from "./sedp-token"; + +interface Attribute { + ATT_ID: string; + VALUE: any; + VALUE_DBL: number; + UOM_ID: string | null; +} + +interface TagEntry { + TAG_NO: string; + TAG_DESC: string; + EP_ID: string; + TAG_TYPE_ID: string; + CLS_ID: string; + ATTRIBUTES: Attribute[]; + [key: string]: any; +} + +interface Column { + key: string; + label: string; + type: string; + shi?: boolean; +} + +/** + * 태그 가져오기 서비스 함수 + * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장 + * + * @param formCode 양식 코드 + * @param projectCode 프로젝트 코드 + * @param packageId 계약 아이템 ID (contractItemId) + * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수 + * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등) + */ +export async function importTagsFromSEDP( + formCode: string, + projectCode: string, + packageId: number, + progressCallback?: (progress: number) => void +): Promise<{ + processedCount: number; + excludedCount: number; + totalEntries: number; + errors?: string[]; +}> { + try { + // 진행 상황 보고 + if (progressCallback) progressCallback(5); + + // 에러 수집 배열 + const errors: string[] = []; + + // SEDP API에서 태그 데이터 가져오기 + const tagData = await fetchTagDataFromSEDP(projectCode, formCode); + + // 데이터 형식 처리 + const tableName = Object.keys(tagData)[0]; + if (!tableName || !tagData[tableName]) { + throw new Error("Invalid tag data format from SEDP API"); + } + + const tagEntries: TagEntry[] = tagData[tableName]; + if (!Array.isArray(tagEntries) || tagEntries.length === 0) { + return { + processedCount: 0, + excludedCount: 0, + totalEntries: 0, + errors: ["No tag entries found in API response"] + }; + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(20); + + // 프로젝트 ID 가져오기 + const projectRecord = await db.select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (!projectRecord || projectRecord.length === 0) { + throw new Error(`Project not found for code: ${projectCode}`); + } + + const projectId = projectRecord[0].id; + + // 양식 메타데이터 가져오기 + const formMetaRecord = await db.select({ columns: formMetas.columns }) + .from(formMetas) + .where(and( + eq(formMetas.projectId, projectId), + eq(formMetas.formCode, formCode) + )) + .limit(1); + + if (!formMetaRecord || formMetaRecord.length === 0) { + throw new Error(`Form metadata not found for formCode: ${formCode} and projectId: ${projectId}`); + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(30); + + // 컬럼 정보 파싱 + const columnsJSON: Column[] = JSON.parse(formMetaRecord[0].columns as string); + + // 현재 formEntries 데이터 가져오기 + const existingEntries = await db.select({ id: formEntries.id, data: formEntries.data }) + .from(formEntries) + .where(and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, packageId) + )); + + // 진행 상황 보고 + if (progressCallback) progressCallback(50); + + // 기존 데이터를 맵으로 변환하여 태그 번호로 빠르게 조회할 수 있게 함 + const existingTagMap = new Map(); + existingEntries.forEach(entry => { + const data = entry.data as any[]; + data.forEach(item => { + if (item.TAG_NO) { + existingTagMap.set(item.TAG_NO, { + entryId: entry.id, + data: item + }); + } + }); + }); + + // 진행 상황 보고 + if (progressCallback) progressCallback(60); + + // 처리 결과 카운터 + let processedCount = 0; + let excludedCount = 0; + + // 새로운 태그 데이터와 업데이트할 데이터 준비 + const newTagData: any[] = []; + const updateData: {entryId: number, tagNo: string, updates: any}[] = []; + + // SEDP 태그 데이터 처리 + for (const tagEntry of tagEntries) { + try { + if (!tagEntry.TAG_NO) { + excludedCount++; + errors.push(`Missing TAG_NO in tag entry`); + continue; + } + + // 기본 태그 데이터 객체 생성 + const tagObject: any = { + TAG_NO: tagEntry.TAG_NO, + TAG_DESC: tagEntry.TAG_DESC || "" + }; + + // ATTRIBUTES 필드에서 shi=true인 컬럼의 값 추출 + if (Array.isArray(tagEntry.ATTRIBUTES)) { + for (const attr of tagEntry.ATTRIBUTES) { + // 해당 어트리뷰트가 양식 메타에 있는지 확인 + const columnInfo = columnsJSON.find(col => col.key === attr.ATT_ID); + if (columnInfo) { + // shi가 true인 컬럼이거나 필수 컬럼만 처리 + if (columnInfo.shi === true) { + // 값 타입에 따른 변환 + if (columnInfo.type === "NUMBER") { + // // 먼저 VALUE_DBL이 있는지 확인 + // if (attr.VALUE_DBL !== undefined && attr.VALUE_DBL !== null) { + // tagObject[attr.ATT_ID] = attr.VALUE_DBL; + // } + // VALUE_DBL이 없으면 VALUE 사용 시도 + if (attr.VALUE !== undefined && attr.VALUE !== null) { + // 문자열에서 숫자 추출 + if (typeof attr.VALUE === 'string') { + // 문자열에서 첫 번째 숫자 부분 추출 + const numberMatch = attr.VALUE.match(/(-?\d+(\.\d+)?)/); + if (numberMatch) { + tagObject[attr.ATT_ID] = parseFloat(numberMatch[0]); + } else { + // 숫자로 직접 변환 시도 + const parsed = parseFloat(attr.VALUE); + if (!isNaN(parsed)) { + tagObject[attr.ATT_ID] = parsed; + } + } + } else if (typeof attr.VALUE === 'number') { + // 이미 숫자인 경우 + tagObject[attr.ATT_ID] = attr.VALUE; + } + } + } else if (attr.VALUE !== null && attr.VALUE !== undefined) { + // 숫자 타입이 아닌 경우 VALUE 그대로 사용 + tagObject[attr.ATT_ID] = attr.VALUE; + } + } + } + } + } + // 기존 태그가 있는지 확인하고 처리 + const existingTag = existingTagMap.get(tagEntry.TAG_NO); + if (existingTag) { + // 기존 태그가 있으면 업데이트할 필드 찾기 + const updates: any = {}; + let hasUpdates = false; + + // shi=true인 필드만 업데이트 + for (const key of Object.keys(tagObject)) { + if (key === "TAG_NO") continue; // TAG_NO는 업데이트 안 함 + + // TAG_DESC는 항상 업데이트 + if (key === "TAG_DESC" && tagObject[key] !== existingTag.data[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + continue; + } + + // 그 외 필드는 컬럼 정보에서 shi=true인 것만 업데이트 + const columnInfo = columnsJSON.find(col => col.key === key); + if (columnInfo && columnInfo.shi === true) { + if (existingTag.data[key] !== tagObject[key]) { + updates[key] = tagObject[key]; + hasUpdates = true; + } + } + } + + // 업데이트할 내용이 있으면 추가 + if (hasUpdates) { + updateData.push({ + entryId: existingTag.entryId, + tagNo: tagEntry.TAG_NO, + updates + }); + } + } else { + // 기존 태그가 없으면 새로 추가 + newTagData.push(tagObject); + } + + processedCount++; + } catch (error) { + excludedCount++; + errors.push(`Error processing tag ${tagEntry.TAG_NO || 'unknown'}: ${error}`); + } + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(80); + + // 업데이트 실행 + for (const update of updateData) { + try { + const entry = existingEntries.find(e => e.id === update.entryId); + if (!entry) continue; + + const data = entry.data as any[]; + const updatedData = data.map(item => { + if (item.TAG_NO === update.tagNo) { + return { ...item, ...update.updates }; + } + return item; + }); + + await db.update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, update.entryId)); + } catch (error) { + errors.push(`Error updating tag ${update.tagNo}: ${error}`); + } + } + + // 새 태그 추가 + if (newTagData.length > 0) { + // 기존 엔트리가 있으면 첫 번째 것에 추가 + if (existingEntries.length > 0) { + const firstEntry = existingEntries[0]; + const existingData = firstEntry.data as any[]; + const updatedData = [...existingData, ...newTagData]; + + await db.update(formEntries) + .set({ + data: updatedData, + updatedAt: new Date() + }) + .where(eq(formEntries.id, firstEntry.id)); + } else { + // 기존 엔트리가 없으면 새로 생성 + await db.insert(formEntries) + .values({ + formCode, + contractItemId: packageId, + data: newTagData, + createdAt: new Date(), + updatedAt: new Date() + }); + } + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(100); + + // 최종 결과 반환 + return { + processedCount, + excludedCount, + totalEntries: tagEntries.length, + errors: errors.length > 0 ? errors : undefined + }; + } catch (error: any) { + console.error("Tag import error:", error); + throw error; + } +} + +/** + * SEDP API에서 태그 데이터 가져오기 + * + * @param projectCode 프로젝트 코드 + * @param formCode 양식 코드 + * @returns API 응답 데이터 + */ +async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { + try { + // Get the token + const apiKey = await getSEDPToken(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + // Make the API call + const response = await fetch( + `${SEDP_API_BASE_URL}/Data/GetPubData`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + REG_TYPE_ID: formCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data; + } catch (error: any) { + console.error('Error calling SEDP API:', error); + throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`); + } +}
\ No newline at end of file diff --git a/lib/sedp/get-tags.ts b/lib/sedp/get-tags.ts new file mode 100644 index 00000000..7c5661c3 --- /dev/null +++ b/lib/sedp/get-tags.ts @@ -0,0 +1,263 @@ +// lib/sedp/get-tag.ts +import db from "@/db/db"; +import { + contractItems, + tags, + forms, + items, + tagTypeClassFormMappings, + projects, + tagTypes, + tagClasses +} from "@/db/schema"; +import { eq, and, like } from "drizzle-orm"; +import { getSEDPToken } from "./sedp-token"; + +/** + * 태그 가져오기 서비스 함수 + * contractItemId(packageId)를 기반으로 외부 시스템에서 태그 데이터를 가져와 DB에 저장 + * + * @param packageId 계약 아이템 ID (contractItemId) + * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수 + * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등) + */ +// 함수 반환 타입 업데이트 +export async function importTagsFromSEDP( + packageId: number, + progressCallback?: (progress: number) => void +): Promise<{ + processedCount: number; + excludedCount: number; + totalEntries: number; + errors?: string[]; +}> { + try { + // 진행 상황 보고 + if (progressCallback) progressCallback(5); + + // Step 1: Get the contract item to find relevant data + const contractItem = await db.query.contractItems.findFirst({ + where: eq(contractItems.id, packageId) + }); + + if (!contractItem) { + throw new Error(`Contract item with ID ${packageId} not found`); + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(5); + + // Step 1-2: Get the item using itemId from contractItem + const item = await db.query.items.findFirst({ + where: eq(items.id, contractItem.itemId) + }); + + if (!item) { + throw new Error(`Item with ID ${contractItem.itemId} not found`); + } + + const itemCode = item.itemCode; + + // 진행 상황 보고 + if (progressCallback) progressCallback(10); + + // Step 2: Find the mapping entry with the item code in remark field + // 더 유연한 검색 패턴 사용 (%itemCode%) + const mapping = await db.query.tagTypeClassFormMappings.findFirst({ + where: like(tagTypeClassFormMappings.remark, `%${itemCode}%`) + }); + + if (!mapping) { + throw new Error(`No mapping found for item code ${itemCode}`); + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(15); + + // Step 3: Get the project code + const project = await db.query.projects.findFirst({ + where: eq(projects.id, mapping.projectId) + }); + + if (!project) { + throw new Error(`Project with ID ${mapping.projectId} not found`); + } + + const projectCode = project.code; + const formCode = mapping.formCode; + + // 진행 상황 보고 + if (progressCallback) progressCallback(20); + + // Step 4: Find the form ID + const form = await db.query.forms.findFirst({ + where: and( + eq(forms.contractItemId, packageId), + eq(forms.formCode, formCode) + ) + }); + + let formId = form?.id; + + // If form doesn't exist, create it + if (!form) { + const insertResult = await db.insert(forms).values({ + contractItemId: packageId, + formCode: formCode, + formName: mapping.formName + }).returning({ id: forms.id }); + + if (insertResult.length === 0) { + throw new Error('Failed to create form record'); + } + + formId = insertResult[0].id; + } + + // 진행 상황 보고 + if (progressCallback) progressCallback(30); + + // Step 5: Call the external API to get tag data + const tagData = await fetchTagDataFromSEDP(projectCode, formCode); + + // 진행 상황 보고 + if (progressCallback) progressCallback(50); + + // Step 6: Process the data and insert into the tags table + let processedCount = 0; + let excludedCount = 0; + const errors: string[] = []; + + // Get the first key from the response as the table name + const tableName = Object.keys(tagData)[0]; + const tagEntries = tagData[tableName]; + + if (!Array.isArray(tagEntries) || tagEntries.length === 0) { + throw new Error('No tag data found in the API response'); + } + + const totalEntries = tagEntries.length; + + // Process each tag entry + for (let i = 0; i < tagEntries.length; i++) { + try { + const entry = tagEntries[i]; + + // TAG_TYPE_ID가 null이거나 빈 문자열인 경우 제외 + if (entry.TAG_TYPE_ID === null || entry.TAG_TYPE_ID === "") { + excludedCount++; + + // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트) + if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { + progressCallback(Math.floor(50 + (i / tagEntries.length) * 50)); + } + + continue; // 이 항목은 건너뜀 + } + + // Get tag type description + const tagType = await db.query.tagTypes.findFirst({ + where: and( + eq(tagTypes.code, entry.TAG_TYPE_ID), + eq(tagTypes.projectId, mapping.projectId) + ) + }); + + // Get tag class label + const tagClass = await db.query.tagClasses.findFirst({ + where: and( + eq(tagClasses.code, entry.CLS_ID), + eq(tagClasses.projectId, mapping.projectId) + ) + }); + + // Insert or update the tag + await db.insert(tags).values({ + contractItemId: packageId, + formId: formId, + tagNo: entry.TAG_NO, + tagType: tagType?.description || entry.TAG_TYPE_ID, + class: tagClass?.label || entry.CLS_ID, + description: entry.TAG_DESC + }).onConflictDoUpdate({ + target: [tags.contractItemId, tags.tagNo], + set: { + formId: formId, + tagType: tagType?.description || entry.TAG_TYPE_ID, + class: tagClass?.label || entry.CLS_ID, + description: entry.TAG_DESC, + updatedAt: new Date() + } + }); + + processedCount++; + + // 주기적으로 진행 상황 보고 + if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { + progressCallback(Math.floor(50 + (i / tagEntries.length) * 50)); + } + } catch (error: any) { + console.error(`Error processing tag entry:`, error); + errors.push(error.message || 'Unknown error'); + } + } + + // 최종 결과 반환 + return { + processedCount, + excludedCount, + totalEntries, + errors: errors.length > 0 ? errors : undefined + }; + } catch (error: any) { + console.error("Tag import error:", error); + throw error; + } +} + +/** + * SEDP API에서 태그 데이터 가져오기 + * + * @param projectCode 프로젝트 코드 + * @param formCode 양식 코드 + * @returns API 응답 데이터 + */ +async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Promise<any> { + try { + // Get the token + const apiKey = await getSEDPToken(); + + // Define the API base URL + const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; + + // Make the API call + const response = await fetch( + `${SEDP_API_BASE_URL}/Data/GetPubData`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + REG_TYPE_ID: formCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); + } + + const data = await response.json(); + return data; + } catch (error: any) { + console.error('Error calling SEDP API:', error); + throw new Error(`Failed to fetch data from SEDP API: ${error.message || 'Unknown error'}`); + } +}
\ No newline at end of file diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts index b9e6fa90..a3caa809 100644 --- a/lib/sedp/sync-form.ts +++ b/lib/sedp/sync-form.ts @@ -1,13 +1,42 @@ // src/lib/cron/syncTagFormMappings.ts import db from "@/db/db"; -import { projects, tagTypes, tagClasses, tagTypeClassFormMappings, formMetas } from '@/db/schema'; -import { eq, and, inArray } from 'drizzle-orm'; +import { projects, tagTypes, tagClasses, tagTypeClassFormMappings, formMetas, forms, contractItems, items } from '@/db/schema'; +import { eq, and, inArray, ilike } from 'drizzle-orm'; import { getSEDPToken } from "./sedp-token"; // 환경 변수 const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/dev/api'; // 인터페이스 정의 +interface TagTypeClassFormMapping { + projectId: number; + tagTypeLabel: string; + classLabel: string; + formCode: string; + formName: string; + remark: string | null; + ep: string; + createdAt: Date; + updatedAt: Date; +} + +interface FormMeta { + projectId: number; + formCode: string; + formName: string; + columns: string; // JSON 문자열 + createdAt: Date; + updatedAt: Date; +} + +interface FormRecord { + contractItemId: number; + formCode: string; + formName: string; + eng: boolean; + createdAt: Date; + updatedAt: Date; +} interface Register { PROJ_NO: string; TYPE_ID: string; @@ -137,6 +166,87 @@ interface FormColumn { options?: string[]; uom?: string; uomId?: string; + shi?: Boolean; +} + +// 아이템 코드 추출 함수 +function extractItemCodes(remark: string | null): string[] { + if (!remark) return []; + + // 검색용으로만 소문자로 변환 + const remarkLower = remark.toLowerCase(); + + // 'vd_' 접두사 확인 + const hasVD_ = remarkLower.includes("vd_"); + + if (!hasVD_) return []; + + let vdPart = ""; + + // 'vd_'가 있으면 원본 문자열에서 추출 (소문자 버전이 아님) + if (hasVD_) { + const vdIndex = remarkLower.indexOf("vd_"); + vdPart = remark.substring(vdIndex + 3); // 원본 문자열에서 추출 + } + + if (!vdPart) return []; + + // 쉼표로 구분된 여러 itemCode 처리 + return vdPart.split(",").map(code => code.trim()); +} + +async function getDefaulTAttributes(): Promise<string[]> { + try { + const apiKey = await getSEDPToken(); + + const response = await fetch( + `${SEDP_API_BASE_URL}/Dictionary/GetByKey`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + }, + body: JSON.stringify({ + Key: "DefaultAttributesToCompare", + }) + } + ); + + if (!response.ok) { + if (response.status === 404) { + console.warn(`디폴트 속성 찾을 수 없음`); + return []; + } + throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`); + } + + // 안전하게 JSON 파싱 + try { + const data = await response.json(); + // 데이터가 배열인지 확인하고 문자열 배열로 변환 + if (Array.isArray(data)) { + return data as string[]; + } else { + console.warn('응답이 배열 형식이 아닙니다'); + return []; + } + } catch (parseError) { + console.error(`디폴트 속성 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + try { + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + } catch (textError) { + console.error('응답 내용 로깅 실패:', textError); + } + return []; + } + } catch (error) { + console.error(`디폴트 어트리뷰트 가져오기 실패:`, error); + throw error; + } } // 레지스터 데이터 가져오기 @@ -144,7 +254,7 @@ async function getRegisters(projectCode: string): Promise<Register[]> { try { // 토큰(API 키) 가져오기 const apiKey = await getSEDPToken(); - + const response = await fetch( `${SEDP_API_BASE_URL}/Register/Get`, { @@ -156,36 +266,123 @@ async function getRegisters(projectCode: string): Promise<Register[]> { 'ProjectNo': projectCode }, body: JSON.stringify({ - ContainDeleted: true + ProjectNo: projectCode, + ContainDeleted: false }) } ); - + if (!response.ok) { throw new Error(`레지스터 요청 실패: ${response.status} ${response.statusText}`); } - - const data = await response.json(); - - // 결과가 배열인지 확인 - if (Array.isArray(data)) { - return data; - } else { - // 단일 객체인 경우 배열로 변환 - return [data]; + + // 안전하게 JSON 파싱 + let data; + try { + data = await response.json(); + } catch (parseError) { + console.error(`프로젝트 ${projectCode}의 레지스터 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + throw new Error(`레지스터 응답 파싱 실패: ${parseError instanceof Error ? parseError.message : String(parseError)}`); } + + // 결과를 배열로 변환 (단일 객체인 경우 배열로 래핑) + let registers: Register[] = Array.isArray(data) ? data : [data]; + + // MAP_CLS_ID가 비어있지 않고 REMARK가 vd, VD, vD, Vd 중 하나인 레지스터만 필터링 + registers = registers.filter(register => { + // 삭제된 레지스터 제외 + if (register.DELETED) return false; + + // MAP_CLS_ID 배열이 존재하고 요소가 하나 이상 있는지 확인 + const hasValidMapClsId = Array.isArray(register.MAP_CLS_ID) && register.MAP_CLS_ID.length > 0; + + // REMARK가 'vd_' 또는 'vd' 포함 확인 (대소문자 구분 없이) + const remarkLower = register.REMARK && register.REMARK.toLowerCase(); + const hasValidRemark = remarkLower && (remarkLower.includes('vd')); + + // 두 조건 모두 충족해야 함 + return hasValidMapClsId && hasValidRemark; + }); + + console.log(`프로젝트 ${projectCode}에서 ${registers.length}개의 유효한 레지스터를 가져왔습니다.`); + return registers; } catch (error) { console.error(`프로젝트 ${projectCode}의 레지스터 가져오기 실패:`, error); throw error; } } -// 특정 속성 가져오기 -async function getAttributeById(projectCode: string, attributeId: string): Promise<Attribute | null> { +// 프로젝트의 모든 속성을 가져와 맵으로 반환 +async function getAttributes(projectCode: string): Promise<Map<string, Attribute>> { try { // 토큰(API 키) 가져오기 const apiKey = await getSEDPToken(); - + + const response = await fetch( + `${SEDP_API_BASE_URL}/Attributes/Get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + throw new Error(`속성 요청 실패: ${response.status} ${response.statusText}`); + } + + // 안전하게 JSON 파싱 + try { + const data = await response.json(); + + // 데이터가 배열인지 확인 + const attributes: Attribute[] = Array.isArray(data) ? data : [data]; + + // ATT_ID로 효율적인 조회를 위한 맵 생성 + const attributeMap = new Map<string, Attribute>(); + for (const attribute of attributes) { + if (!attribute.DELETED) { + attributeMap.set(attribute.ATT_ID, attribute); + } + } + + console.log(`프로젝트 ${projectCode}에서 ${attributeMap.size}개의 속성을 가져왔습니다`); + return attributeMap; + + } catch (parseError) { + console.error(`프로젝트 ${projectCode}의 속성 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + try { + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + } catch (textError) { + console.error('응답 내용 로깅 실패:', textError); + } + return new Map(); + } + } catch (error) { + console.error(`프로젝트 ${projectCode}의 속성 가져오기 실패:`, error); + return new Map(); + } +} + +// 특정 속성 가져오기 (하위 호환성을 위해 유지) +async function getAttributeById(projectCode: string, attributeId: string, register: string): Promise<Attribute | null> { + try { + // 토큰(API 키) 가져오기 + const apiKey = await getSEDPToken(); + const response = await fetch( `${SEDP_API_BASE_URL}/Attributes/GetByID`, { @@ -197,11 +394,13 @@ async function getAttributeById(projectCode: string, attributeId: string): Promi 'ProjectNo': projectCode }, body: JSON.stringify({ - ATT_ID: attributeId + ProjectNo: projectCode, + ATT_ID: attributeId, + ContainDeleted: false }) } ); - + if (!response.ok) { if (response.status === 404) { console.warn(`속성 ID ${attributeId}를 찾을 수 없음`); @@ -209,20 +408,96 @@ async function getAttributeById(projectCode: string, attributeId: string): Promi } throw new Error(`속성 요청 실패: ${response.status} ${response.statusText}`); } - - return response.json(); + + // 안전하게 JSON 파싱 + try { + const data = await response.json(); + return data; + } catch (parseError) { + console.error(`속성 ID ${attributeId} ${register} ${projectCode} 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + try { + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + } catch (textError) { + console.error('응답 내용 로깅 실패:', textError); + } + return null; + } } catch (error) { console.error(`속성 ID ${attributeId} 가져오기 실패:`, error); return null; } } -// 특정 코드 리스트 가져오기 +// 프로젝트의 모든 코드 리스트를 가져와 맵으로 반환 +async function getCodeLists(projectCode: string): Promise<Map<string, CodeList>> { + try { + // 토큰(API 키) 가져오기 + const apiKey = await getSEDPToken(); + + const response = await fetch( + `${SEDP_API_BASE_URL}/CodeList/Get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`); + } + + // 안전하게 JSON 파싱 + try { + const data = await response.json(); + + // 데이터가 배열인지 확인 + const codeLists: CodeList[] = Array.isArray(data) ? data : [data]; + + // CL_ID로 효율적인 조회를 위한 맵 생성 + const codeListMap = new Map<string, CodeList>(); + for (const codeList of codeLists) { + if (!codeList.DELETED) { + codeListMap.set(codeList.CL_ID, codeList); + } + } + + console.log(`프로젝트 ${projectCode}에서 ${codeListMap.size}개의 코드 리스트를 가져왔습니다`); + return codeListMap; + + } catch (parseError) { + console.error(`프로젝트 ${projectCode}의 코드 리스트 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + try { + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + } catch (textError) { + console.error('응답 내용 로깅 실패:', textError); + } + return new Map(); + } + } catch (error) { + console.error(`프로젝트 ${projectCode}의 코드 리스트 가져오기 실패:`, error); + return new Map(); + } +} + +// 특정 코드 리스트 가져오기 (하위 호환성을 위해 유지) async function getCodeListById(projectCode: string, codeListId: string): Promise<CodeList | null> { try { // 토큰(API 키) 가져오기 const apiKey = await getSEDPToken(); - + const response = await fetch( `${SEDP_API_BASE_URL}/CodeList/GetByID`, { @@ -234,11 +509,13 @@ async function getCodeListById(projectCode: string, codeListId: string): Promise 'ProjectNo': projectCode }, body: JSON.stringify({ - CL_ID: codeListId + ProjectNo: projectCode, + CL_ID: codeListId, + ContainDeleted: false }) } ); - + if (!response.ok) { if (response.status === 404) { console.warn(`코드 리스트 ID ${codeListId}를 찾을 수 없음`); @@ -246,20 +523,96 @@ async function getCodeListById(projectCode: string, codeListId: string): Promise } throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`); } - - return response.json(); + + // 안전하게 JSON 파싱 + try { + const data = await response.json(); + return data; + } catch (parseError) { + console.error(`코드 리스트 ID ${codeListId} 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + try { + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + } catch (textError) { + console.error('응답 내용 로깅 실패:', textError); + } + return null; + } } catch (error) { console.error(`코드 리스트 ID ${codeListId} 가져오기 실패:`, error); return null; } } -// UOM 가져오기 +// 프로젝트의 모든 UOM을 가져와 맵으로 반환 +async function getUOMs(projectCode: string): Promise<Map<string, UOM>> { + try { + // 토큰(API 키) 가져오기 + const apiKey = await getSEDPToken(); + + const response = await fetch( + `${SEDP_API_BASE_URL}/UOM/Get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': apiKey, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + throw new Error(`UOM 요청 실패: ${response.status} ${response.statusText}`); + } + + // 안전하게 JSON 파싱 + try { + const data = await response.json(); + + // 데이터가 배열인지 확인 + const uoms: UOM[] = Array.isArray(data) ? data : [data]; + + // UOM_ID로 효율적인 조회를 위한 맵 생성 + const uomMap = new Map<string, UOM>(); + for (const uom of uoms) { + if (!uom.DELETED) { + uomMap.set(uom.UOM_ID, uom); + } + } + + console.log(`프로젝트 ${projectCode}에서 ${uomMap.size}개의 UOM을 가져왔습니다`); + return uomMap; + + } catch (parseError) { + console.error(`프로젝트 ${projectCode}의 UOM 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + try { + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + } catch (textError) { + console.error('응답 내용 로깅 실패:', textError); + } + return new Map(); + } + } catch (error) { + console.error(`프로젝트 ${projectCode}의 UOM 가져오기 실패:`, error); + return new Map(); + } +} + +// UOM 가져오기 (하위 호환성을 위해 유지) async function getUomById(projectCode: string, uomId: string): Promise<UOM | null> { try { // 토큰(API 키) 가져오기 const apiKey = await getSEDPToken(); - + const response = await fetch( `${SEDP_API_BASE_URL}/UOM/GetByID`, { @@ -271,11 +624,13 @@ async function getUomById(projectCode: string, uomId: string): Promise<UOM | nul 'ProjectNo': projectCode }, body: JSON.stringify({ - UOM_ID: uomId + UOMID: uomId, // API 명세서에 따라 UOMID 사용 + ProjectNo: projectCode, + ContainDeleted: false }) } ); - + if (!response.ok) { if (response.status === 404) { console.warn(`UOM ID ${uomId}를 찾을 수 없음`); @@ -283,90 +638,215 @@ async function getUomById(projectCode: string, uomId: string): Promise<UOM | nul } throw new Error(`UOM 요청 실패: ${response.status} ${response.statusText}`); } - - return response.json(); + + // 안전하게 JSON 파싱 + try { + const data = await response.json(); + return data; + } catch (parseError) { + console.error(`UOM ID ${uomId} 응답 파싱 실패:`, parseError); + // 응답 내용 로깅 + try { + const text = await response.clone().text(); + console.log(`응답 내용: ${text.substring(0, 200)}${text.length > 200 ? '...' : ''}`); + } catch (textError) { + console.error('응답 내용 로깅 실패:', textError); + } + return null; + } } catch (error) { console.error(`UOM ID ${uomId} 가져오기 실패:`, error); return null; } } +// contractItemId 조회 함수 +async function getContractItemsByItemCodes(itemCodes: string[]): Promise<Map<string, number>> { + try { + if (!itemCodes.length) return new Map(); + + // 먼저 itemCodes에 해당하는 item 레코드를 조회 + const itemRecords = await db.select({ + id: items.id, + itemCode: items.itemCode + }) + .from(items) + .where(inArray(items.itemCode, itemCodes)); + + if (!itemRecords.length) { + console.log(`No items found for itemCodes: ${itemCodes.join(', ')}`); + return new Map(); + } + + // item ID 목록 추출 + const itemIds = itemRecords.map(item => item.id); + + // contractItems 조회 + const contractItemRecords = await db.select({ + id: contractItems.id, + itemId: contractItems.itemId + }) + .from(contractItems) + .where(inArray(contractItems.itemId, itemIds)); + + // itemCode와 contractItemId의 매핑 생성 + const itemCodeToContractItemId = new Map<string, number>(); + + for (const item of itemRecords) { + // itemCode가 null이 아닌 경우에만 처리 + if (item.itemCode) { + const matchedContractItems = contractItemRecords.filter(ci => ci.itemId === item.id); + if (matchedContractItems.length > 0) { + // 일치하는 첫 번째 contractItem 사용 + itemCodeToContractItemId.set(item.itemCode, matchedContractItems[0].id); + } + } + } + + return itemCodeToContractItemId; + } catch (error) { + console.error('ContractItems 조회 중 오류 발생:', error); + return new Map(); + } +} + // 데이터베이스에 태그 타입 클래스 폼 매핑 및 폼 메타 저장 async function saveFormMappingsAndMetas( - projectId: number, + projectId: number, projectCode: string, registers: Register[] ): Promise<number> { try { - // 프로젝트와 관련된 태그 타입 및 클래스 가져오기 + // 프로젝트의 태그 타입과 클래스 가져오기 const tagTypeRecords = await db.select() .from(tagTypes) .where(eq(tagTypes.projectId, projectId)); - + const tagClassRecords = await db.select() .from(tagClasses) .where(eq(tagClasses.projectId, projectId)); - - // 태그 타입과 클래스를 매핑 + + // 태그 타입과 클래스 매핑 const tagTypeMap = new Map(tagTypeRecords.map(type => [type.code, type])); const tagClassMap = new Map(tagClassRecords.map(cls => [cls.code, cls])); + + // 모든 속성, 코드 리스트, UOM을 한 번에 가져와 반복 API 호출 방지 + const attributeMap = await getAttributes(projectCode); + const codeListMap = await getCodeLists(projectCode); + const uomMap = await getUOMs(projectCode); - // 저장할 매핑 목록과 폼 메타 정보 - const mappingsToSave = []; - const formMetasToSave = []; + // 기본 속성 가져오기 + const defaultAttributes = await getDefaulTAttributes(); + + // 모든 register에서 itemCode를 추출하여 한 번에 조회 + const allItemCodes: string[] = []; + registers.forEach(register => { + if (register.REMARK) { + const itemCodes = extractItemCodes(register.REMARK); + allItemCodes.push(...itemCodes); + } + }); + + // 중복 제거 + const uniqueItemCodes = [...new Set(allItemCodes)]; - // 각 레지스터 처리 + // 모든 itemCode에 대한 contractItemId 조회 + const itemCodeToContractItemId = await getContractItemsByItemCodes(uniqueItemCodes); + + console.log(`${uniqueItemCodes.length}개의 고유 itemCode 중 ${itemCodeToContractItemId.size}개의 contractItem을 찾았습니다`); + + // 저장할 데이터 준비 + const mappingsToSave: TagTypeClassFormMapping[] = []; + const formMetasToSave: FormMeta[] = []; + const formsToSave: FormRecord[] = []; + + // 폼이 있는 contractItemId 트래킹 + const contractItemIdsWithForms = new Set<number>(); + + // 각 register 처리 for (const register of registers) { - // 삭제된 레지스터는 건너뜀 + // 삭제된 register 건너뛰기 if (register.DELETED) continue; - - // 폼 메타 데이터를 위한 컬럼 정보 구성 + + // REMARK에서 itemCodes 추출 + const itemCodes = extractItemCodes(register.REMARK || ''); + if (!itemCodes.length) { + console.log(`Register ${register.TYPE_ID} (${register.DESC})의 REMARK에 유효한 itemCode가 없습니다`); + continue; + } + + // 폼 메타용 columns 구성 const columns: FormColumn[] = []; - - // 각 속성 정보 수집 + for (const linkAtt of register.LNK_ATT) { - // 속성 가져오기 - const attribute = await getAttributeById(projectCode, linkAtt.ATT_ID); - - if (!attribute) continue; - - // 기본 컬럼 정보 + let attribute = null; + + // 기본 속성인지 확인 + if (defaultAttributes && defaultAttributes.includes(linkAtt.ATT_ID)) { + // 기본 속성에 대한 기본 attribute 객체 생성 + attribute = { + DESC: linkAtt.ATT_ID, + VAL_TYPE: 'STRING' + }; + } else { + // 맵에서 속성 조회 + attribute = attributeMap.get(linkAtt.ATT_ID); + + // 속성을 찾지 못한 경우 다음으로 넘어감 + if (!attribute) continue; + } + + // 컬럼 정보 생성 const column: FormColumn = { key: linkAtt.ATT_ID, - label: linkAtt.CPY_DESC, - type: attribute.VAL_TYPE || 'STRING' + label: attribute.DESC, + type: (attribute.VAL_TYPE === 'LIST' || attribute.VAL_TYPE === 'DYNAMICLIST') + ? 'LIST' + : (attribute.VAL_TYPE || 'STRING'), + shi: attribute.REMARK?.toLocaleLowerCase() === "shi" }; - - // 리스트 타입인 경우 옵션 추가 - if ((attribute.VAL_TYPE === 'LIST' || attribute.VAL_TYPE === 'DYNAMICLIST') && attribute.CL_ID) { - const codeList = await getCodeListById(projectCode, attribute.CL_ID); + + // 리스트 타입에 대한 옵션 추가 (기본 속성이 아닌 경우) + if (!defaultAttributes.includes(linkAtt.ATT_ID) && + (attribute.VAL_TYPE === 'LIST' || attribute.VAL_TYPE === 'DYNAMICLIST') && + attribute.CL_ID) { + // 맵에서 코드 리스트 조회 + const codeList = codeListMap.get(attribute.CL_ID); + if (codeList && codeList.VALUES) { - // 유효한 옵션만 필터링 - const options = codeList.VALUES - .filter(value => value.USE_YN) - .map(value => value.DESC); - + const options = [...new Set( + codeList.VALUES + .filter(value => value.USE_YN) + .map(value => value.VALUE) + )]; + if (options.length > 0) { column.options = options; } } } - + // UOM 정보 추가 if (linkAtt.UOM_ID) { - const uom = await getUomById(projectCode, linkAtt.UOM_ID); - + const uom = uomMap.get(linkAtt.UOM_ID); + if (uom) { column.uom = uom.SYMBOL; column.uomId = uom.UOM_ID; } } - + columns.push(column); } - - // 폼 메타 정보 저장 + + // 컬럼이 없으면 건너뛰기 + if (columns.length === 0) { + console.log(`폼 ${register.TYPE_ID} (${register.DESC})에 컬럼이 없어 건너뜁니다`); + continue; + } + + // 폼 메타 데이터 준비 formMetasToSave.push({ projectId, formCode: register.TYPE_ID, @@ -375,25 +855,24 @@ async function saveFormMappingsAndMetas( createdAt: new Date(), updatedAt: new Date() }); - - // 관련된 클래스 매핑 처리 + + // 클래스 매핑 처리 for (const classId of register.MAP_CLS_ID) { - // 해당 클래스와 태그 타입 확인 const tagClass = tagClassMap.get(classId); - + if (!tagClass) { - console.warn(`클래스 ID ${classId}를 프로젝트 ID ${projectId}에서 찾을 수 없음`); + console.warn(`프로젝트 ID ${projectId}에서 클래스 ID ${classId}를 찾을 수 없습니다`); continue; } - + const tagTypeCode = tagClass.tagTypeCode; const tagType = tagTypeMap.get(tagTypeCode); - + if (!tagType) { - console.warn(`태그 타입 ${tagTypeCode}를 프로젝트 ID ${projectId}에서 찾을 수 없음`); + console.warn(`프로젝트 ID ${projectId}에서 태그 타입 ${tagTypeCode}를 찾을 수 없습니다`); continue; } - + // 매핑 정보 저장 mappingsToSave.push({ projectId, @@ -401,32 +880,71 @@ async function saveFormMappingsAndMetas( classLabel: tagClass.label, formCode: register.TYPE_ID, formName: register.DESC, + remark: register.REMARK, + ep: register.EP_ID, + createdAt: new Date(), + updatedAt: new Date() + }); + } + + // 폼 레코드 준비 + for (const itemCode of itemCodes) { + const contractItemId = itemCodeToContractItemId.get(itemCode); + + if (!contractItemId) { + console.warn(`itemCode: ${itemCode}에 대한 contractItemId를 찾을 수 없습니다`); + continue; + } + + // 폼이 있는 contractItemId 추적 + contractItemIdsWithForms.add(contractItemId); + + formsToSave.push({ + contractItemId, + formCode: register.TYPE_ID, + formName: register.DESC, + eng: true, createdAt: new Date(), updatedAt: new Date() }); } } - - // 기존 데이터 삭제 후 새로 저장 - await db.delete(tagTypeClassFormMappings).where(eq(tagTypeClassFormMappings.projectId, projectId)); - await db.delete(formMetas).where(eq(formMetas.projectId, projectId)); - + + // 트랜잭션으로 모든 작업 처리 let totalSaved = 0; - - // 매핑 정보 저장 - if (mappingsToSave.length > 0) { - await db.insert(tagTypeClassFormMappings).values(mappingsToSave); - totalSaved += mappingsToSave.length; - console.log(`프로젝트 ID ${projectId}에 ${mappingsToSave.length}개의 태그 타입-클래스-폼 매핑 저장 완료`); - } - - // 폼 메타 정보 저장 - if (formMetasToSave.length > 0) { - await db.insert(formMetas).values(formMetasToSave); - totalSaved += formMetasToSave.length; - console.log(`프로젝트 ID ${projectId}에 ${formMetasToSave.length}개의 폼 메타 정보 저장 완료`); - } - + + await db.transaction(async (tx) => { + // 기존 데이터 삭제 + await tx.delete(tagTypeClassFormMappings).where(eq(tagTypeClassFormMappings.projectId, projectId)); + await tx.delete(formMetas).where(eq(formMetas.projectId, projectId)); + + // 해당 contractItemId에 대한 기존 폼 삭제 + if (contractItemIdsWithForms.size > 0) { + await tx.delete(forms).where(inArray(forms.contractItemId, [...contractItemIdsWithForms])); + } + + // 매핑 저장 + if (mappingsToSave.length > 0) { + await tx.insert(tagTypeClassFormMappings).values(mappingsToSave); + totalSaved += mappingsToSave.length; + console.log(`프로젝트 ID ${projectId}에 대해 ${mappingsToSave.length}개의 태그 타입-클래스-폼 매핑을 저장했습니다`); + } + + // 폼 메타 저장 + if (formMetasToSave.length > 0) { + await tx.insert(formMetas).values(formMetasToSave); + totalSaved += formMetasToSave.length; + console.log(`프로젝트 ID ${projectId}에 대해 ${formMetasToSave.length}개의 폼 메타 레코드를 저장했습니다`); + } + + // 폼 레코드 저장 + if (formsToSave.length > 0) { + await tx.insert(forms).values(formsToSave); + totalSaved += formsToSave.length; + console.log(`프로젝트 ID ${projectId}에 대해 ${formsToSave.length}개의 폼 레코드를 저장했습니다`); + } + }); + return totalSaved; } catch (error) { console.error(`폼 매핑 및 메타 저장 실패 (프로젝트 ID: ${projectId}):`, error); @@ -438,39 +956,39 @@ async function saveFormMappingsAndMetas( export async function syncTagFormMappings() { try { console.log('태그 폼 매핑 동기화 시작:', new Date().toISOString()); - + // 모든 프로젝트 가져오기 const allProjects = await db.select().from(projects); - + // 각 프로젝트에 대해 폼 매핑 동기화 const results = await Promise.allSettled( allProjects.map(async (project: Project) => { try { // 레지스터 데이터 가져오기 const registers = await getRegisters(project.code); - + // 데이터베이스에 저장 const count = await saveFormMappingsAndMetas(project.id, project.code, registers); - return { - project: project.code, - success: true, - count + return { + project: project.code, + success: true, + count } as SyncResult; } catch (error) { console.error(`프로젝트 ${project.code} 폼 매핑 동기화 실패:`, error); - return { - project: project.code, - success: false, - error: error instanceof Error ? error.message : String(error) + return { + project: project.code, + success: false, + error: error instanceof Error ? error.message : String(error) } as SyncResult; } }) ); - + // 결과 처리를 위한 배열 준비 const successfulResults: SyncResult[] = []; const failedResults: SyncResult[] = []; - + // 결과 분류 results.forEach((result) => { if (result.status === 'fulfilled') { @@ -488,19 +1006,19 @@ export async function syncTagFormMappings() { }); } }); - + const successCount = successfulResults.length; const failCount = failedResults.length; - + // 이제 안전하게 count 속성에 접근 가능 - const totalItems = successfulResults.reduce((sum, result) => + const totalItems = successfulResults.reduce((sum, result) => sum + (result.count || 0), 0 ); - + console.log(`태그 폼 매핑 동기화 완료: ${successCount}개 프로젝트 성공 (총 ${totalItems}개 항목), ${failCount}개 프로젝트 실패`); - - return { - success: successCount, + + return { + success: successCount, failed: failCount, items: totalItems, timestamp: new Date().toISOString() diff --git a/lib/sedp/sync-object-class.ts b/lib/sedp/sync-object-class.ts index 1cf0c23b..0a76c592 100644 --- a/lib/sedp/sync-object-class.ts +++ b/lib/sedp/sync-object-class.ts @@ -40,7 +40,12 @@ interface SyncResult { count?: number; error?: string; } - +interface TagType { + TYPE_ID: string; + DESC: string; + PROJ_NO: string; + // 기타 필드들... +} // 오브젝트 클래스 데이터 가져오기 async function getObjectClasses(projectCode: string, token:string): Promise<ObjectClass[]> { try { @@ -55,7 +60,8 @@ async function getObjectClasses(projectCode: string, token:string): Promise<Obje 'ProjectNo': projectCode }, body: JSON.stringify({ - ContainDeleted: true + ProjectNo:projectCode, + ContainDeleted: false }) } ); @@ -95,11 +101,171 @@ async function verifyTagTypes(projectId: number, tagTypeCodes: string[]): Promis } } -// 데이터베이스에 오브젝트 클래스 저장 (upsert 사용) -async function saveObjectClassesToDatabase(projectId: number, classes: ObjectClass[]): Promise<number> { +async function saveTagTypesToDatabase(allTagTypes: TagType[], projectCode: string): Promise<void> { + try { + if (allTagTypes.length === 0) { + console.log(`프로젝트 ${projectCode}에 저장할 태그 타입이 없습니다.`); + return; + } + + // 프로젝트 코드로 프로젝트 ID 조회 + const projectResult = await db.select({ id: projects.id }) + .from(projects) + .where(eq(projects.code, projectCode)) + .limit(1); + + if (projectResult.length === 0) { + throw new Error(`프로젝트 코드 ${projectCode}에 해당하는 프로젝트를 찾을 수 없습니다.`); + } + + const projectId = projectResult[0].id; + + // 현재 프로젝트의 모든 태그 타입 조회 + const existingTagTypes = await db.select() + .from(tagTypes) + .where(eq(tagTypes.projectId, projectId)); + + // 코드 기준으로 맵 생성 + const existingTagTypeMap = new Map( + existingTagTypes.map(type => [type.code, type]) + ); + + // API에 있는 코드 목록 + const apiTagTypeCodes = new Set(allTagTypes.map(type => type.TYPE_ID)); + + // 삭제할 코드 목록 + const codesToDelete = existingTagTypes + .map(type => type.code) + .filter(code => !apiTagTypeCodes.has(code)); + + // 새로 추가할 항목 + const toInsert = []; + + // 업데이트할 항목 + const toUpdate = []; + + // 태그 타입 데이터 처리 + for (const tagType of allTagTypes) { + // 데이터베이스 레코드 준비 + const record = { + code: tagType.TYPE_ID, + projectId: projectId, + description: tagType.DESC, + updatedAt: new Date() + }; + + // 이미 존재하는 코드인지 확인 + if (existingTagTypeMap.has(tagType.TYPE_ID)) { + // 업데이트 항목에 추가 + toUpdate.push(record); + } else { + // 새로 추가할 항목에 추가 (createdAt 필드 추가) + toInsert.push({ + ...record, + createdAt: new Date() + }); + } + } + + // 트랜잭션 실행 + + // 1. 새 항목 삽입 + if (toInsert.length > 0) { + await db.insert(tagTypes).values(toInsert); + console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 태그 타입 추가 완료`); + } + + // 2. 기존 항목 업데이트 + for (const item of toUpdate) { + await db.update(tagTypes) + .set({ + description: item.description, + updatedAt: item.updatedAt + }) + .where( + and( + eq(tagTypes.code, item.code), + eq(tagTypes.projectId, item.projectId) + ) + ); + } + + if (toUpdate.length > 0) { + console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 태그 타입 업데이트 완료`); + } + + // 3. 더 이상 존재하지 않는 항목 삭제 + if (codesToDelete.length > 0) { + for (const code of codesToDelete) { + await db.delete(tagTypes) + .where( + and( + eq(tagTypes.code, code), + eq(tagTypes.projectId, projectId) + ) + ); + } + console.log(`프로젝트 ID ${projectId}에서 ${codesToDelete.length}개의 태그 타입 삭제 완료`); + } + + console.log(`프로젝트 ${projectCode}(ID: ${projectId})의 태그 타입 동기화 완료`); + } catch (error) { + console.error(`태그 타입 저장 실패 (프로젝트: ${projectCode}):`, error); + throw error; + } +} + +async function getAllTagTypes(projectCode: string, token: string): Promise<TagType[]> { + try { + console.log(`프로젝트 ${projectCode}의 모든 태그 타입 가져오기 시작`); + + const response = await fetch( + `${SEDP_API_BASE_URL}/TagType/Get`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'accept': '*/*', + 'ApiKey': token, + 'ProjectNo': projectCode + }, + body: JSON.stringify({ + ProjectNo: projectCode, + ContainDeleted: false + }) + } + ); + + if (!response.ok) { + throw new Error(`태그 타입 요청 실패: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + // 결과가 배열인지 확인 + if (Array.isArray(data)) { + return data; + } else { + // 단일 객체인 경우 배열로 변환 + return [data]; + } + } catch (error) { + console.error('태그 타입 목록 가져오기 실패:', error); + throw error; + } +} + +// 4. 기존 함수 수정 - saveObjectClassesToDatabase +async function saveObjectClassesToDatabase( + projectId: number, + classes: ObjectClass[], + projectCode: string, + token: string, + skipTagTypeSync: boolean = false // 태그 타입 동기화를 건너뛸지 여부 +): Promise<number> { try { // null이 아닌 TAG_TYPE_ID만 필터링 - const validClasses = classes.filter(cls => cls.TAG_TYPE_ID !== null); + const validClasses = classes.filter(cls => cls.TAG_TYPE_ID !== null && cls.TAG_TYPE_ID !== "") ; if (validClasses.length === 0) { console.log(`프로젝트 ID ${projectId}에 저장할 유효한 오브젝트 클래스가 없습니다.`); @@ -109,6 +275,25 @@ async function saveObjectClassesToDatabase(projectId: number, classes: ObjectCla // 모든 태그 타입 ID 목록 추출 const tagTypeCodes = validClasses.map(cls => cls.TAG_TYPE_ID!); + // skipTagTypeSync가 true인 경우 태그 타입 동기화 단계 건너뜀 + if (!skipTagTypeSync) { + // 태그 타입이 없는 경우를 대비해 태그 타입 정보 먼저 가져와서 저장 + console.log(`프로젝트 ID ${projectId}의 태그 타입 동기화 시작...`); + + try { + // 프로젝트의 모든 태그 타입 가져오기 + const allTagTypes = await getAllTagTypes(projectCode, token); + + // 태그 타입 저장 + await saveTagTypesToDatabase(allTagTypes, projectCode); + } catch (error) { + console.error(`프로젝트 ${projectCode}의 태그 타입 동기화 실패:`, error); + // 에러가 발생해도 계속 진행 + } + + console.log(`프로젝트 ID ${projectId}의 태그 타입 동기화 완료`); + } + // 존재하는 태그 타입 확인 const existingTagTypeCodes = await verifyTagTypes(projectId, tagTypeCodes); @@ -122,6 +307,7 @@ async function saveObjectClassesToDatabase(projectId: number, classes: ObjectCla return 0; } + // 이하 기존 코드와 동일 // 현재 프로젝트의 오브젝트 클래스 코드 가져오기 const existingClasses = await db.select() .from(tagClasses) @@ -223,7 +409,7 @@ async function saveObjectClassesToDatabase(projectId: number, classes: ObjectCla } } -// 메인 동기화 함수 +// 5. 메인 동기화 함수 수정 export async function syncObjectClasses() { try { console.log('오브젝트 클래스 동기화 시작:', new Date().toISOString()); @@ -234,15 +420,55 @@ export async function syncObjectClasses() { // 2. 모든 프로젝트 가져오기 const allProjects = await db.select().from(projects); - // 3. 각 프로젝트에 대해 오브젝트 클래스 동기화 + // 3. 모든 프로젝트에 대해 먼저 태그 타입 동기화 (바로 이 부분이 추가됨) + console.log('모든 프로젝트의 태그 타입 동기화 시작...'); + + const tagTypeResults = await Promise.allSettled( + allProjects.map(async (project: Project) => { + try { + console.log(`프로젝트 ${project.code}의 태그 타입 동기화 시작...`); + // 프로젝트의 모든 태그 타입 가져오기 + const allTagTypes = await getAllTagTypes(project.code, token); + + // 태그 타입 저장 + await saveTagTypesToDatabase(allTagTypes, project.code); + console.log(`프로젝트 ${project.code}의 태그 타입 동기화 완료`); + + return { + project: project.code, + success: true, + count: allTagTypes.length + }; + } catch (error) { + console.error(`프로젝트 ${project.code}의 태그 타입 동기화 실패:`, error); + return { + project: project.code, + success: false, + error: error instanceof Error ? error.message : String(error) + }; + } + }) + ); + + // 태그 타입 동기화 결과 집계 + const tagTypeSuccessCount = tagTypeResults.filter( + result => result.status === 'fulfilled' && result.value.success + ).length; + + const tagTypeFailCount = tagTypeResults.length - tagTypeSuccessCount; + + console.log(`모든 프로젝트의 태그 타입 동기화 완료: ${tagTypeSuccessCount}개 성공, ${tagTypeFailCount}개 실패`); + + // 4. 각 프로젝트에 대해 오브젝트 클래스 동기화 (태그 타입 동기화는 건너뜀) const results = await Promise.allSettled( allProjects.map(async (project: Project) => { try { // 오브젝트 클래스 데이터 가져오기 const objectClasses = await getObjectClasses(project.code, token); - // 데이터베이스에 저장 - const count = await saveObjectClassesToDatabase(project.id, objectClasses); + // 데이터베이스에 저장 (skipTagTypeSync를 true로 설정하여 태그 타입 동기화 건너뜀) + const count = await saveObjectClassesToDatabase(project.id, objectClasses, project.code, token, true); + return { project: project.code, success: true, @@ -291,10 +517,17 @@ export async function syncObjectClasses() { console.log(`오브젝트 클래스 동기화 완료: ${successCount}개 프로젝트 성공 (총 ${totalItems}개 항목), ${failCount}개 프로젝트 실패`); + // 전체 결과에 태그 타입 동기화 결과도 포함 return { - success: successCount, - failed: failCount, - items: totalItems, + tagTypeSync: { + success: tagTypeSuccessCount, + failed: tagTypeFailCount + }, + objectClassSync: { + success: successCount, + failed: failCount, + items: totalItems + }, timestamp: new Date().toISOString() }; } catch (error) { diff --git a/lib/sedp/sync-projects.ts b/lib/sedp/sync-projects.ts index 1094b55f..0f5ed2a8 100644 --- a/lib/sedp/sync-projects.ts +++ b/lib/sedp/sync-projects.ts @@ -38,15 +38,12 @@ async function getProjects(): Promise<Project[]> { const response = await fetch( `${SEDP_API_BASE_URL}/Project/Get`, { - method: 'POST', + method: 'GET', headers: { 'Content-Type': 'application/json', 'accept': '*/*', 'ApiKey': apiKey - }, - body: JSON.stringify({ - ContainDeleted: true - }) + } } ); diff --git a/lib/sedp/sync-tag-types.ts b/lib/sedp/sync-tag-types.ts index 2d19fc19..8233badd 100644 --- a/lib/sedp/sync-tag-types.ts +++ b/lib/sedp/sync-tag-types.ts @@ -118,7 +118,7 @@ async function getTagTypes(projectCode: string, token: string): Promise<TagType[ }, body: JSON.stringify({ ProjectNo: projectCode, - ContainDeleted: true + ContainDeleted: false }) } ); @@ -149,7 +149,7 @@ async function getAttributes(projectCode: string, token: string): Promise<Attrib }, body: JSON.stringify({ ProjectNo: projectCode, - ContainDeleted: true + ContainDeleted: false }) } ); @@ -170,7 +170,7 @@ async function getAttributes(projectCode: string, token: string): Promise<Attrib async function getCodeList(projectCode: string, codeListId: string, token: string): Promise<CodeList | null> { try { const response = await fetch( - `${SEDP_API_BASE_URL}/CodeList/Get`, + `${SEDP_API_BASE_URL}/CodeList/GetByID`, { method: 'POST', headers: { @@ -182,7 +182,7 @@ async function getCodeList(projectCode: string, codeListId: string, token: strin body: JSON.stringify({ ProjectNo: projectCode, CL_ID: codeListId, - ContainDeleted: true + ContainDeleted: false }) } ); @@ -299,34 +299,64 @@ async function processAndSaveTagSubfields( // 1. 새 서브필드 삽입 if (toInsert.length > 0) { - await db.insert(tagSubfields).values(toInsert); - totalChanged += toInsert.length; - console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 태그 서브필드 추가 완료`); + // 중복 제거를 위한 Map 생성 (마지막 항목만 유지) + const uniqueInsertMap = new Map(); + + for (const item of toInsert) { + const compositeKey = `${item.projectId}:${item.tagTypeCode}:${item.attributesId}`; + uniqueInsertMap.set(compositeKey, item); + } + + // 중복이 제거된 배열 생성 + const deduplicatedInserts = Array.from(uniqueInsertMap.values()); + + // 중복 제거된 항목만 삽입 + await db.insert(tagSubfields).values(deduplicatedInserts); + + // 중복 제거 전후 개수 로그 + console.log(`프로젝트 ID ${projectId}에 ${deduplicatedInserts.length}개의 새 태그 서브필드 추가 완료 (중복 제거 전: ${toInsert.length}개)`); + totalChanged += deduplicatedInserts.length; } - // 2. 기존 서브필드 업데이트 - for (const item of toUpdate) { - await db.update(tagSubfields) - .set({ - attributesDescription: item.attributesDescription, - expression: item.expression, - delimiter: item.delimiter, - sortOrder: item.sortOrder, - updatedAt: item.updatedAt - }) - .where( - and( - eq(tagSubfields.projectId, item.projectId), - eq(tagSubfields.tagTypeCode, item.tagTypeCode), - eq(tagSubfields.attributesId, item.attributesId) - ) - ); - totalChanged += 1; - } - if (toUpdate.length > 0) { - console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 태그 서브필드 업데이트 완료`); - } + // 2. 기존 서브필드 업데이트 +if (toUpdate.length > 0) { + // 중복 제거를 위한 Map 생성 (마지막 항목만 유지) + const uniqueUpdateMap = new Map(); + + for (const item of toUpdate) { + const compositeKey = `${item.projectId}:${item.tagTypeCode}:${item.attributesId}`; + uniqueUpdateMap.set(compositeKey, item); + } + + // 중복이 제거된 배열 생성 + const deduplicatedUpdates = Array.from(uniqueUpdateMap.values()); + + // 중복 제거 전후 개수 로그 + console.log(`프로젝트 ID ${projectId}의 ${deduplicatedUpdates.length}개 태그 서브필드 업데이트 시작 (중복 제거 전: ${toUpdate.length}개)`); + + // 각 항목 개별 업데이트 + for (const item of deduplicatedUpdates) { + await db.update(tagSubfields) + .set({ + attributesDescription: item.attributesDescription, + expression: item.expression, + delimiter: item.delimiter, + sortOrder: item.sortOrder, + updatedAt: item.updatedAt + }) + .where( + and( + eq(tagSubfields.projectId, item.projectId), + eq(tagSubfields.tagTypeCode, item.tagTypeCode), + eq(tagSubfields.attributesId, item.attributesId) + ) + ); + totalChanged += 1; + } + + console.log(`프로젝트 ID ${projectId}의 ${deduplicatedUpdates.length}개 태그 서브필드 업데이트 완료`); +} // 3. 더 이상 존재하지 않는 서브필드 삭제 if (keysToDelete.length > 0) { diff --git a/lib/tag-numbering/service.ts b/lib/tag-numbering/service.ts index 6041f07c..3256b185 100644 --- a/lib/tag-numbering/service.ts +++ b/lib/tag-numbering/service.ts @@ -86,13 +86,13 @@ export async function getTagNumbering(input: GetTagNumberigSchema) { - export const fetchTagSubfieldOptions = (async (attributesId: string): Promise<TagSubfieldOption[]> => { + export const fetchTagSubfieldOptions = (async (attributesId: string,projectId:number ): Promise<TagSubfieldOption[]> => { try { // (A) findMany -> 스키마 제네릭 누락 에러 발생 → 대신 select().from().where() 사용 const rows = await db .select() .from(tagSubfieldOptions) - .where(eq(tagSubfieldOptions.attributesId, attributesId)) + .where(and(eq(tagSubfieldOptions.attributesId, attributesId),eq(tagSubfieldOptions.projectId, projectId))) .orderBy(asc(tagSubfieldOptions.code)) // rows는 TagSubfieldOption[] 형태 diff --git a/lib/tag-numbering/table/meta-sheet.tsx b/lib/tag-numbering/table/meta-sheet.tsx index 4221837c..fd14e117 100644 --- a/lib/tag-numbering/table/meta-sheet.tsx +++ b/lib/tag-numbering/table/meta-sheet.tsx @@ -64,7 +64,7 @@ export function ViewTagOptions({ setLoading(true) try { // 서버 액션 호출 - attributesId와 일치하는 모든 옵션 가져오기 - const optionsData = await fetchTagSubfieldOptions(tagSubfield.attributesId) + const optionsData = await fetchTagSubfieldOptions(tagSubfield.attributesId, tagSubfield.projectId) setOptions(optionsData || []) } catch (error) { console.error("Error fetching tag options:", error) diff --git a/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx index 7a14817f..9200e81b 100644 --- a/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx +++ b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx @@ -23,7 +23,7 @@ export function TagNumberingTableToolbarActions({ table }: ItemsTableToolbarActi setIsLoading(true) // API 엔드포인트 호출 - const response = await fetch('/api/cron/object-classes') + const response = await fetch('/api/cron/tag-types') if (!response.ok) { const errorData = await response.json() diff --git a/lib/tag-numbering/table/tagNumbering-table.tsx b/lib/tag-numbering/table/tagNumbering-table.tsx index 6ca46e05..847b3eeb 100644 --- a/lib/tag-numbering/table/tagNumbering-table.tsx +++ b/lib/tag-numbering/table/tagNumbering-table.tsx @@ -132,17 +132,27 @@ export function TagNumberingTable({ promises }: ItemsTableProps) { shallow: false, clearOnDefault: true, }) + const [isCompact, setIsCompact] = React.useState<boolean>(false) + + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + return ( <> <DataTable table={table} + compact={isCompact} > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} shallow={false} + enableCompactToggle={true} + compactStorageKey="tagNumberingTableCompact" + onCompactChange={handleCompactChange} > <TagNumberingTableToolbarActions table={table} /> </DataTableAdvancedToolbar> diff --git a/lib/tags/service.ts b/lib/tags/service.ts index 8477b1fb..b02f5dc2 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -7,7 +7,7 @@ import { createTagSchema, GetTagsSchema, updateTagSchema, UpdateTagSchema, type import { revalidateTag, unstable_noStore } from "next/cache"; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne ,count,isNull} from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne, count, isNull } from "drizzle-orm"; import { countTags, insertTag, selectTags } from "./repository"; import { getErrorMessage } from "../handle-error"; import { getFormMappingsByTagType } from './form-mapping-service'; @@ -29,7 +29,7 @@ export async function getTags(input: GetTagsSchema, packagesId: number) { try { const offset = (input.page - 1) * input.perPage; - // (1) advancedWhere + // (1) advancedWhere const advancedWhere = filterColumns({ table: tags, filters: input.filters, @@ -110,14 +110,14 @@ export async function createTag( return await db.transaction(async (tx) => { // 1) 선택된 contractItem의 contractId 가져오기 const contractItemResult = await tx - .select({ - contractId: contractItems.contractId, - projectId: contracts.projectId // projectId 추가 - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 - .where(eq(contractItems.id, selectedPackageId)) - .limit(1) + .select({ + contractId: contractItems.contractId, + projectId: contracts.projectId // projectId 추가 + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) if (contractItemResult.length === 0) { return { error: "Contract item not found" } @@ -160,7 +160,7 @@ export async function createTag( projectId ) } - + // 4) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성 let primaryFormId: number | null = null @@ -199,6 +199,7 @@ export async function createTag( contractItemId: selectedPackageId, formCode: formMapping.formCode, formName: formMapping.formName, + im: true }) .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName }) @@ -253,6 +254,125 @@ export async function createTag( } } +export async function createTagInForm( + formData: CreateTagSchema, + selectedPackageId: number | null, + formCode: string +) { + if (!selectedPackageId) { + return { error: "No selectedPackageId provided" } + } + + // Validate formData + const validated = createTagSchema.safeParse(formData) + if (!validated.success) { + return { error: validated.error.flatten().formErrors.join(", ") } + } + + // React 서버 액션에서 매 요청마다 실행 + unstable_noStore() + + try { + // 하나의 트랜잭션에서 모든 작업 수행 + return await db.transaction(async (tx) => { + // 1) 선택된 contractItem의 contractId 가져오기 + const contractItemResult = await tx + .select({ + contractId: contractItems.contractId, + projectId: contracts.projectId // projectId 추가 + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) + + if (contractItemResult.length === 0) { + return { error: "Contract item not found" } + } + + const contractId = contractItemResult[0].contractId + const projectId = contractItemResult[0].projectId + + // 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인 + const duplicateCheck = await tx + .select({ count: sql<number>`count(*)` }) + .from(tags) + .innerJoin(contractItems, eq(tags.contractItemId, contractItems.id)) + .where( + and( + eq(contractItems.contractId, contractId), + eq(tags.tagNo, validated.data.tagNo) + ) + ) + + if (duplicateCheck[0].count > 0) { + return { + error: `태그 번호 "${validated.data.tagNo}"는 이미 이 계약 내에 존재합니다.`, + } + } + + const form = await db.query.forms.findFirst({ + where: eq(forms.formCode, formCode) + }); + + if (form?.id) { + // 5) 새 Tag 생성 (같은 트랜잭션 `tx` 사용) + const [newTag] = await insertTag(tx, { + contractItemId: selectedPackageId, + formId: form.id, + tagNo: validated.data.tagNo, + class: validated.data.class, + tagType: validated.data.tagType, + description: validated.data.description ?? null, + }) + + let updatedData: Array<{ + TAG_NO: string; + TAG_DESC?: string; + }> = []; + + updatedData.push({ + TAG_NO: validated.data.tagNo, + TAG_DESC: validated.data.description ?? null, + }); + + const entry = await db.query.formEntries.findFirst({ + where: and( + eq(formEntries.formCode, formCode), + eq(formEntries.contractItemId, selectedPackageId), + ) + }); + + if (entry && entry.id && updatedData.length > 0) { + await db + .update(formEntries) + .set({ data: updatedData }) + .where(eq(formEntries.id, entry.id)); + } + + console.log(`tags-${selectedPackageId}`, "create", newTag) + + } + + // 6) 캐시 무효화 (React 서버 액션에서 캐싱 사용 시) + revalidateTag(`tags-${selectedPackageId}`) + revalidateTag(`forms-${selectedPackageId}`) + revalidateTag("tags") + + // 7) 성공 시 반환 + return { + success: true, + data: null + } + }) + } catch (err: any) { + console.log("createTag in Form error:", err) + + console.error("createTag in Form error:", err) + return { error: getErrorMessage(err) } + } +} + export async function updateTag( formData: UpdateTagSchema & { id: number }, selectedPackageId: number | null @@ -292,14 +412,14 @@ export async function updateTag( // 2) 선택된 contractItem의 contractId 가져오기 const contractItemResult = await tx - .select({ - contractId: contractItems.contractId, - projectId: contracts.projectId // projectId 추가 - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 - .where(eq(contractItems.id, selectedPackageId)) - .limit(1) + .select({ + contractId: contractItems.contractId, + projectId: contracts.projectId // projectId 추가 + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 + .where(eq(contractItems.id, selectedPackageId)) + .limit(1) if (contractItemResult.length === 0) { return { error: "Contract item not found" } @@ -330,8 +450,8 @@ export async function updateTag( } // 4) 태그 타입이나 클래스가 변경되었는지 확인 - const isTagTypeOrClassChanged = - originalTag.tagType !== validated.data.tagType || + const isTagTypeOrClassChanged = + originalTag.tagType !== validated.data.tagType || originalTag.class !== validated.data.class let primaryFormId = originalTag.formId @@ -459,17 +579,17 @@ export async function bulkCreateTags( selectedPackageId: number ) { unstable_noStore(); - + if (!tagsfromExcel.length) { return { error: "No tags provided" }; } - + try { // 단일 트랜잭션으로 모든 작업 처리 return await db.transaction(async (tx) => { // 1. 컨트랙트 ID 및 프로젝트 ID 조회 (한 번만) const contractItemResult = await tx - .select({ + .select({ contractId: contractItems.contractId, projectId: contracts.projectId // projectId 추가 }) @@ -477,14 +597,14 @@ export async function bulkCreateTags( .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인 .where(eq(contractItems.id, selectedPackageId)) .limit(1); - + if (contractItemResult.length === 0) { return { error: "Contract item not found" }; } - + const contractId = contractItemResult[0].contractId; const projectId = contractItemResult[0].projectId; // projectId 추출 - + // 2. 모든 태그 번호 중복 검사 (한 번에) const tagNos = tagsfromExcel.map(tag => tag.tagNo); const duplicateCheck = await tx @@ -495,24 +615,24 @@ export async function bulkCreateTags( eq(contractItems.contractId, contractId), inArray(tags.tagNo, tagNos) )); - + if (duplicateCheck.length > 0) { return { error: `태그 번호 "${duplicateCheck.map(d => d.tagNo).join(', ')}"는 이미 존재합니다.` }; } - + // 3. 태그별 폼 정보 처리 및 태그 생성 const createdTags = []; const allFormsInfo = []; // 모든 태그에 대한 폼 정보 저장 // 태그 유형별 폼 매핑 캐싱 (성능 최적화) const formMappingsCache = new Map(); - + for (const tagData of tagsfromExcel) { // 캐시 키 생성 (tagType + class) const cacheKey = `${tagData.tagType}|${tagData.class || 'NONE'}`; - + // 폼 매핑 가져오기 (캐시 사용) let formMappings; if (formMappingsCache.has(cacheKey)) { @@ -526,11 +646,11 @@ export async function bulkCreateTags( ); formMappingsCache.set(cacheKey, formMappings); } - + // 폼 처리 로직 let primaryFormId: number | null = null; const createdOrExistingForms: CreatedOrExistingForm[] = []; - + if (formMappings && formMappings.length > 0) { for (const formMapping of formMappings) { // 해당 폼이 이미 존재하는지 확인 @@ -590,7 +710,7 @@ export async function bulkCreateTags( projectId ); } - + // 태그 생성 const [newTag] = await insertTag(tx, { contractItemId: selectedPackageId, @@ -600,9 +720,9 @@ export async function bulkCreateTags( tagType: tagData.tagType, description: tagData.description || null, }); - + createdTags.push(newTag); - + // 해당 태그의 폼 정보 저장 allFormsInfo.push({ tagNo: tagData.tagNo, @@ -610,12 +730,12 @@ export async function bulkCreateTags( primaryFormId, }); } - + // 4. 캐시 무효화 (한 번만) revalidateTag(`tags-${selectedPackageId}`); revalidateTag(`forms-${selectedPackageId}`); revalidateTag("tags"); - + return { success: true, data: { @@ -644,33 +764,33 @@ function removeTagFromDataJson( tagNo: string ): any { // data 구조가 어떻게 생겼는지에 따라 로직이 달라집니다. - // 예: data 배열 안에 { tagNumber: string, ... } 형태로 여러 객체가 있다고 가정 + // 예: data 배열 안에 { TAG_NO: string, ... } 형태로 여러 객체가 있다고 가정 if (!Array.isArray(dataJson)) return dataJson - return dataJson.filter((entry) => entry.tagNumber !== tagNo) + return dataJson.filter((entry) => entry.TAG_NO !== tagNo) } export async function removeTags(input: RemoveTagsInput) { unstable_noStore() // React 서버 액션 무상태 함수 - + const { ids, selectedPackageId } = input - + try { await db.transaction(async (tx) => { const packageInfo = await tx - .select({ - projectId: contracts.projectId - }) - .from(contractItems) - .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) - .where(eq(contractItems.id, selectedPackageId)) - .limit(1); - - if (packageInfo.length === 0) { - throw new Error(`Contract item with ID ${selectedPackageId} not found`); - } - - const projectId = packageInfo[0].projectId; + .select({ + projectId: contracts.projectId + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); + + if (packageInfo.length === 0) { + throw new Error(`Contract item with ID ${selectedPackageId} not found`); + } + + const projectId = packageInfo[0].projectId; // 1) 삭제 대상 tag들을 미리 조회 const tagsToDelete = await tx @@ -682,7 +802,7 @@ export async function removeTags(input: RemoveTagsInput) { }) .from(tags) .where(inArray(tags.id, ids)) - + // 2) 태그 타입과 클래스의 고유 조합 추출 const uniqueTypeClassCombinations = [...new Set( tagsToDelete.map(tag => `${tag.tagType}|${tag.class || ''}`) @@ -690,7 +810,7 @@ export async function removeTags(input: RemoveTagsInput) { const [tagType, classValue] = combo.split('|'); return { tagType, class: classValue || undefined }; }); - + // 3) 각 태그 타입/클래스 조합에 대해 처리 for (const { tagType, class: classValue } of uniqueTypeClassCombinations) { // 3-1) 삭제 중인 태그들 외에, 동일한 태그 타입/클래스를 가진 다른 태그가 있는지 확인 @@ -705,18 +825,18 @@ export async function removeTags(input: RemoveTagsInput) { eq(tags.contractItemId, selectedPackageId) // 같은 contractItemId 내에서만 확인 ) ) - + // 3-2) 이 태그 타입/클래스에 연결된 폼 매핑 가져오기 - const formMappings = await getFormMappingsByTagType(tagType,projectId,classValue); - + const formMappings = await getFormMappingsByTagType(tagType, projectId, classValue); + if (!formMappings.length) continue; - + // 3-3) 이 태그 타입/클래스와 관련된 태그 번호 추출 const relevantTagNos = tagsToDelete - .filter(tag => tag.tagType === tagType && - (classValue ? tag.class === classValue : !tag.class)) + .filter(tag => tag.tagType === tagType && + (classValue ? tag.class === classValue : !tag.class)) .map(tag => tag.tagNo); - + // 3-4) 각 폼 코드에 대해 처리 for (const formMapping of formMappings) { // 다른 태그가 없다면 폼 삭제 @@ -730,7 +850,7 @@ export async function removeTags(input: RemoveTagsInput) { eq(forms.formCode, formMapping.formCode) ) ) - + // formEntries 테이블에서도 해당 formCode 관련 데이터 삭제 await tx .delete(formEntries) @@ -740,7 +860,7 @@ export async function removeTags(input: RemoveTagsInput) { eq(formEntries.formCode, formMapping.formCode) ) ) - } + } // 다른 태그가 있다면 formEntries 데이터에서 해당 태그 정보만 제거 else if (relevantTagNos.length > 0) { const formEntryRecords = await tx @@ -755,16 +875,16 @@ export async function removeTags(input: RemoveTagsInput) { eq(formEntries.formCode, formMapping.formCode) ) ) - + // 각 formEntry에 대해 처리 for (const entry of formEntryRecords) { let updatedJson = entry.data; - + // 각 tagNo에 대해 JSON 데이터에서 제거 for (const tagNo of relevantTagNos) { updatedJson = removeTagFromDataJson(updatedJson, tagNo); } - + // 변경이 있다면 업데이트 await tx .update(formEntries) @@ -774,15 +894,15 @@ export async function removeTags(input: RemoveTagsInput) { } } } - + // 4) 마지막으로 tags 테이블에서 태그들 삭제 await tx.delete(tags).where(inArray(tags.id, ids)) }) - + // 5) 캐시 무효화 revalidateTag(`tags-${selectedPackageId}`) revalidateTag(`forms-${selectedPackageId}`) - + return { data: null, error: null } } catch (err) { return { data: null, error: getErrorMessage(err) } @@ -802,7 +922,28 @@ export interface ClassOption { * Class 옵션 목록을 가져오는 함수 * 이제 각 클래스는 연결된 tagTypeCode와 tagTypeDescription을 포함 */ -export async function getClassOptions(){ +export async function getClassOptions(packageId?: number) { + if (!packageId) { + throw new Error("패키지 ID가 필요합니다"); + } + + // First, get the projectId from the contract associated with the package + const packageInfo = await db + .select({ + projectId: contracts.projectId + }) + .from(contractItems) + .innerJoin(contracts, eq(contracts.id, contractItems.contractId)) + .where(eq(contractItems.id, packageId)) + .limit(1); + + if (!packageInfo.length) { + throw new Error("패키지를 찾을 수 없거나 연결된 프로젝트가 없습니다"); + } + + const projectId = packageInfo[0].projectId; + + // Now get the tag classes filtered by projectId const rows = await db .select({ id: tagClasses.id, @@ -812,16 +953,19 @@ export async function getClassOptions(){ tagTypeDescription: tagTypes.description, }) .from(tagClasses) - .leftJoin(tagTypes, eq(tagTypes.code, tagClasses.tagTypeCode)) + .leftJoin(tagTypes, and( + eq(tagTypes.code, tagClasses.tagTypeCode), + eq(tagTypes.projectId, tagClasses.projectId) + )) + .where(eq(tagClasses.projectId, projectId)); return rows.map((row) => ({ code: row.code, label: row.label, tagTypeCode: row.tagTypeCode, tagTypeDescription: row.tagTypeDescription ?? "", - })) + })); } - interface SubFieldDef { name: string label: string @@ -856,7 +1000,7 @@ export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackage .where( and( eq(tagSubfields.tagTypeCode, tagTypeCode), - eq(tagSubfields.projectId, projectId) + eq(tagSubfields.projectId, projectId) ) ) .orderBy(asc(tagSubfields.sortOrder)); @@ -866,7 +1010,7 @@ export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackage for (const sf of rows) { // projectId가 필요한 경우 getSubfieldType과 getSubfieldOptions 함수에도 전달 const subfieldType = await getSubfieldType(sf.attributesId, projectId); - + const subfieldOptions = subfieldType === "select" ? await getSubfieldOptions(sf.attributesId, projectId) : []; @@ -889,11 +1033,11 @@ export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackage } -async function getSubfieldType(attributesId: string, projectId:number): Promise<"select" | "text"> { +async function getSubfieldType(attributesId: string, projectId: number): Promise<"select" | "text"> { const optRows = await db .select() .from(tagSubfieldOptions) - .where(and(eq(tagSubfieldOptions.attributesId, attributesId),eq(tagSubfieldOptions.projectId,projectId))) + .where(and(eq(tagSubfieldOptions.attributesId, attributesId), eq(tagSubfieldOptions.projectId, projectId))) return optRows.length > 0 ? "select" : "text" } @@ -917,7 +1061,7 @@ export interface SubfieldOption { /** * SubField의 옵션 목록을 가져오는 보조 함수 */ -async function getSubfieldOptions(attributesId: string, projectId:number): Promise<SubfieldOption[]> { +async function getSubfieldOptions(attributesId: string, projectId: number): Promise<SubfieldOption[]> { try { const rows = await db .select({ @@ -927,8 +1071,8 @@ async function getSubfieldOptions(attributesId: string, projectId:number): Promi .from(tagSubfieldOptions) .where( and( - eq(tagSubfieldOptions.attributesId, attributesId), - eq(tagSubfieldOptions.projectId, projectId), + eq(tagSubfieldOptions.attributesId, attributesId), + eq(tagSubfieldOptions.projectId, projectId), ) ) @@ -989,4 +1133,31 @@ export async function getTagTypes(): Promise<{ options: TagTypeOption[] }> { export interface TagTypeOption { id: string; // tagTypes.code 값 label: string; // tagTypes.description 값 +} + +export async function getProjectIdFromContractItemId(contractItemId: number): Promise<number | null> { + try { + // First get the contractId from contractItems + const contractItem = await db.query.contractItems.findFirst({ + where: eq(contractItems.id, contractItemId), + columns: { + contractId: true + } + }); + + if (!contractItem) return null; + + // Then get the projectId from contracts + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractItem.contractId), + columns: { + projectId: true + } + }); + + return contract?.projectId || null; + } catch (error) { + console.error("Error fetching projectId:", error); + return null; + } }
\ No newline at end of file diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx index 8efb6b02..73df5aef 100644 --- a/lib/tags/table/add-tag-dialog.tsx +++ b/lib/tags/table/add-tag-dialog.tsx @@ -116,23 +116,25 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { // --------------- // Load Class Options // --------------- - React.useEffect(() => { - const loadClassOptions = async () => { - setIsLoadingClasses(true) - try { - const result = await getClassOptions() - setClassOptions(result) - } catch (err) { - toast.error("클래스 옵션을 불러오는데 실패했습니다.") - } finally { - setIsLoadingClasses(false) - } +// In the AddTagDialog component +React.useEffect(() => { + const loadClassOptions = async () => { + setIsLoadingClasses(true) + try { + // Pass selectedPackageId to the function + const result = await getClassOptions(selectedPackageId) + setClassOptions(result) + } catch (err) { + toast.error("클래스 옵션을 불러오는데 실패했습니다.") + } finally { + setIsLoadingClasses(false) } + } - if (open) { - loadClassOptions() - } - }, [open]) + if (open) { + loadClassOptions() + } +}, [open, selectedPackageId]) // Add selectedPackageId to the dependency array // --------------- // react-hook-form with fieldArray support for multiple rows diff --git a/lib/tags/table/tag-table.tsx b/lib/tags/table/tag-table.tsx index 5c8c048f..62f0a7c5 100644 --- a/lib/tags/table/tag-table.tsx +++ b/lib/tags/table/tag-table.tsx @@ -31,9 +31,6 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { // 1) 데이터를 가져옴 (server component -> use(...) pattern) const [{ data, pageCount }] = React.use(promises) - - - const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null) const columns = React.useMemo( @@ -87,7 +84,7 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { enablePinning: true, enableAdvancedFilter: true, initialState: { - sorting: [{ id: "createdAt", desc: true }], + // sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, }, getRowId: (originalRow) => String(originalRow.id), @@ -97,16 +94,29 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { }) + const [isCompact, setIsCompact] = React.useState<boolean>(false) + + + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + return ( <> <DataTable table={table} + compact={isCompact} + floatingBar={<TagsTableFloatingBar table={table} selectedPackageId={selectedPackageId}/>} > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} shallow={false} + enableCompactToggle={true} + compactStorageKey="tagTableCompact" + onCompactChange={handleCompactChange} > {/* 4) ToolbarActions에 tableData, setTableData 넘겨서 diff --git a/lib/tags/table/tags-export.tsx b/lib/tags/table/tags-export.tsx index 4afbac6c..fa85148d 100644 --- a/lib/tags/table/tags-export.tsx +++ b/lib/tags/table/tags-export.tsx @@ -15,6 +15,7 @@ import { getClassOptions } from "../service" */ export async function exportTagsToExcel( table: Table<Tag>, + selectedPackageId: number, { filename = "Tags", excludeColumns = ["select", "actions", "createdAt", "updatedAt"], @@ -26,6 +27,8 @@ export async function exportTagsToExcel( } = {} ) { try { + + // 1. 테이블에서 컬럼 정보 가져오기 const allTableColumns = table.getAllLeafColumns() @@ -39,7 +42,7 @@ export async function exportTagsToExcel( const worksheet = workbook.addWorksheet("Tags") // 3. Tag Class 옵션 가져오기 - const classOptions = await getClassOptions() + const classOptions = await getClassOptions(selectedPackageId) // 4. 유효성 검사 시트 생성 const validationSheet = workbook.addWorksheet("ValidationData") diff --git a/lib/tags/table/tags-table-toolbar-actions.tsx b/lib/tags/table/tags-table-toolbar-actions.tsx index 497b2278..c6d13247 100644 --- a/lib/tags/table/tags-table-toolbar-actions.tsx +++ b/lib/tags/table/tags-table-toolbar-actions.tsx @@ -7,13 +7,14 @@ import ExcelJS from "exceljs" import { saveAs } from "file-saver" import { Button } from "@/components/ui/button" -import { Download, Upload, Loader2 } from "lucide-react" +import { Download, Upload, Loader2, RefreshCcw } from "lucide-react" import { Tag, TagSubfields } from "@/db/schema/vendorData" import { exportTagsToExcel } from "./tags-export" import { AddTagDialog } from "./add-tag-dialog" import { fetchTagSubfieldOptions, getTagNumberingRules, } from "@/lib/tag-numbering/service" -import { bulkCreateTags, getClassOptions, getSubfieldsByTagType } from "../service" +import { bulkCreateTags, getClassOptions, getProjectIdFromContractItemId, getSubfieldsByTagType } from "../service" import { DeleteTagsDialog } from "./delete-tags-dialog" +import { useRouter } from "next/navigation" // Add this import // 태그 번호 검증을 위한 인터페이스 interface TagNumberingRule { @@ -68,10 +69,16 @@ export function TagsTableToolbarActions({ selectedPackageId, tableData, }: TagsTableToolbarActionsProps) { + const router = useRouter() // Add this line + const [isPending, setIsPending] = React.useState(false) const [isExporting, setIsExporting] = React.useState(false) const fileInputRef = React.useRef<HTMLInputElement>(null) + const [isLoading, setIsLoading] = React.useState(false) + const [syncId, setSyncId] = React.useState<string | null>(null) + const pollingRef = React.useRef<NodeJS.Timeout | null>(null) + // 태그 타입별 넘버링 룰 캐시 const [tagNumberingRules, setTagNumberingRules] = React.useState<Record<string, TagNumberingRule[]>>({}) const [tagOptionsCache, setTagOptionsCache] = React.useState<Record<string, TagOption[]>>({}) @@ -84,7 +91,7 @@ export function TagsTableToolbarActions({ React.useEffect(() => { const loadClassOptions = async () => { try { - const options = await getClassOptions() + const options = await getClassOptions(selectedPackageId) setClassOptions(options) } catch (error) { console.error("Failed to load class options:", error) @@ -92,7 +99,7 @@ export function TagsTableToolbarActions({ } loadClassOptions() - }, []) + }, [selectedPackageId]) // 숨겨진 <input>을 클릭 function handleImportClick() { @@ -123,28 +130,53 @@ export function TagsTableToolbarActions({ } }, [tagNumberingRules]) + const [projectId, setProjectId] = React.useState<number | null>(null); + + // Add useEffect to fetch projectId when selectedPackageId changes + React.useEffect(() => { + const fetchProjectId = async () => { + if (selectedPackageId) { + try { + const pid = await getProjectIdFromContractItemId(selectedPackageId); + setProjectId(pid); + } catch (error) { + console.error("Failed to fetch project ID:", error); + toast.error("Failed to load project data"); + } + } + }; + + fetchProjectId(); + }, [selectedPackageId]); + // 특정 attributesId에 대한 옵션 가져오기 const fetchOptions = React.useCallback(async (attributesId: string): Promise<TagOption[]> => { - // 이미 캐시에 있으면 캐시된 값 사용 + // Cache check remains the same if (tagOptionsCache[attributesId]) { - return tagOptionsCache[attributesId] + return tagOptionsCache[attributesId]; } - + try { - const options = await fetchTagSubfieldOptions(attributesId) - - // 캐시에 저장 + // Only pass projectId if it's not null + let options: TagOption[]; + if (projectId !== null) { + options = await fetchTagSubfieldOptions(attributesId, projectId); + } else { + options = [] + } + + // Update cache setTagOptionsCache(prev => ({ ...prev, [attributesId]: options - })) - - return options + })); + + return options; } catch (error) { - console.error(`Error fetching options for ${attributesId}:`, error) - return [] + console.error(`Error fetching options for ${attributesId}:`, error); + return []; } - }, [tagOptionsCache]) + }, [tagOptionsCache, projectId]); // 클래스 라벨로 태그 타입 코드 찾기 const getTagTypeCodeByClassLabel = React.useCallback((classLabel: string): string | null => { @@ -527,7 +559,7 @@ export function TagsTableToolbarActions({ setIsExporting(true) // 유효성 검사가 포함된 새로운 엑셀 내보내기 함수 호출 - await exportTagsToExcel(table, { + await exportTagsToExcel(table,selectedPackageId, { filename: `Tags_${selectedPackageId}`, excludeColumns: ["select", "actions", "createdAt", "updatedAt"], }) @@ -541,6 +573,105 @@ export function TagsTableToolbarActions({ } } + const startGetTags = async () => { + try { + setIsLoading(true) + + // API 엔드포인트 호출 - 작업 시작만 요청 + const response = await fetch('/api/cron/tags/start', { + method: 'POST', + body: JSON.stringify({ packageId: selectedPackageId }) + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || 'Failed to start tag import') + } + + const data = await response.json() + + // 작업 ID 저장 + if (data.syncId) { + setSyncId(data.syncId) + toast.info('Tag import started. This may take a while...') + + // 상태 확인을 위한 폴링 시작 + startPolling(data.syncId) + } else { + throw new Error('No import ID returned from server') + } + } catch (error) { + console.error('Error starting tag import:', error) + toast.error( + error instanceof Error + ? error.message + : 'An error occurred while starting tag import' + ) + setIsLoading(false) + } + } + + const startPolling = (id: string) => { + // 이전 폴링이 있다면 제거 + if (pollingRef.current) { + clearInterval(pollingRef.current) + } + + // 5초마다 상태 확인 + pollingRef.current = setInterval(async () => { + try { + const response = await fetch(`/api/cron/tags/status?id=${id}`) + + if (!response.ok) { + throw new Error('Failed to get tag import status') + } + + const data = await response.json() + + if (data.status === 'completed') { + // 폴링 중지 + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + + router.refresh() + + // 상태 초기화 + setIsLoading(false) + setSyncId(null) + + // 성공 메시지 표시 + toast.success( + `Tags imported successfully! ${data.result?.processedCount || 0} items processed.` + ) + + // 테이블 데이터 업데이트 + table.resetRowSelection() + } else if (data.status === 'failed') { + // 에러 처리 + if (pollingRef.current) { + clearInterval(pollingRef.current) + pollingRef.current = null + } + + setIsLoading(false) + setSyncId(null) + toast.error(data.error || 'Import failed') + } else if (data.status === 'processing') { + // 진행 상태 업데이트 (선택적) + if (data.progress) { + toast.info(`Import in progress: ${data.progress}%`, { + id: `import-progress-${id}`, + }) + } + } + } catch (error) { + console.error('Error checking importing status:', error) + } + }, 5000) // 5초마다 체크 + } + return ( <div className="flex items-center gap-2"> @@ -553,7 +684,18 @@ export function TagsTableToolbarActions({ selectedPackageId={selectedPackageId} /> ) : null} - + <Button + variant="samsung" + size="sm" + className="gap-2" + onClick={startGetTags} + disabled={isLoading} + > + <RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" /> + <span className="hidden sm:inline"> + {isLoading ? 'Syncing...' : 'Get Tags'} + </span> + </Button> <AddTagDialog selectedPackageId={selectedPackageId} /> diff --git a/lib/tags/table/update-tag-sheet.tsx b/lib/tags/table/update-tag-sheet.tsx index 7d213fc3..613abaa9 100644 --- a/lib/tags/table/update-tag-sheet.tsx +++ b/lib/tags/table/update-tag-sheet.tsx @@ -102,7 +102,6 @@ export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSh const fieldIdsRef = React.useRef<Record<string, string>>({}) const classOptionIdsRef = React.useRef<Record<string, string>>({}) - console.log(tag) // Load class options when sheet opens React.useEffect(() => { @@ -111,7 +110,7 @@ export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSh setIsLoadingClasses(true) try { - const result = await getClassOptions() + const result = await getClassOptions(selectedPackageId) setClassOptions(result) } catch (err) { toast.error("클래스 옵션을 불러오는데 실패했습니다.") diff --git a/lib/tasks/service.ts b/lib/tasks/service.ts index c31ecd4b..0530ab85 100644 --- a/lib/tasks/service.ts +++ b/lib/tasks/service.ts @@ -1,4 +1,3 @@ -// src/lib/tasks/service.ts "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) import { revalidateTag, unstable_noStore } from "next/cache"; diff --git a/lib/tbe/table/comments-sheet.tsx b/lib/tbe/table/comments-sheet.tsx index 7fcde35d..0952209d 100644 --- a/lib/tbe/table/comments-sheet.tsx +++ b/lib/tbe/table/comments-sheet.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { useForm, useFieldArray } from "react-hook-form" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Download, X } from "lucide-react" +import { Loader, Download, X ,Loader2} from "lucide-react" import prettyBytes from "pretty-bytes" import { toast } from "sonner" @@ -50,7 +50,6 @@ import { // DB 스키마에서 필요한 타입들을 가져온다고 가정 // (실제 프로젝트에 맞춰 import를 수정하세요.) -import { RfqWithAll } from "@/db/schema/rfq" import { formatDate } from "@/lib/utils" import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" @@ -77,6 +76,7 @@ interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { currentUserId: number rfqId:number vendorId:number + isLoading?: boolean // New prop /** 댓글 저장 후 갱신용 콜백 (옵션) */ onCommentsUpdated?: (comments: TbeComment[]) => void } @@ -96,6 +96,7 @@ export function CommentSheet({ initialComments = [], currentUserId, onCommentsUpdated, + isLoading = false, ...props }: CommentSheetProps) { const [comments, setComments] = React.useState<TbeComment[]>(initialComments) @@ -125,6 +126,16 @@ export function CommentSheet({ // 간단히 테이블 하나로 표현 // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 function renderExistingComments() { + + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + if (comments.length === 0) { return <p className="text-sm text-muted-foreground">No comments yet</p> } diff --git a/lib/tbe/table/invite-vendors-dialog.tsx b/lib/tbe/table/invite-vendors-dialog.tsx index 87467e57..59535278 100644 --- a/lib/tbe/table/invite-vendors-dialog.tsx +++ b/lib/tbe/table/invite-vendors-dialog.tsx @@ -39,6 +39,7 @@ interface InviteVendorsDialogProps rfqId: number showTrigger?: boolean onSuccess?: () => void + hasMultipleRfqIds?: boolean } export function InviteVendorsDialog({ @@ -46,6 +47,7 @@ export function InviteVendorsDialog({ rfqId, showTrigger = true, onSuccess, + hasMultipleRfqIds, ...props }: InviteVendorsDialogProps) { const [isInvitePending, startInviteTransition] = React.useTransition() @@ -105,10 +107,14 @@ export function InviteVendorsDialog({ /> </div> ) - + if (hasMultipleRfqIds) { + toast.error("동일한 RFQ에 대해 선택해주세요"); + return; + } // Desktop Dialog if (isDesktop) { return ( + <Dialog {...props}> {showTrigger ? ( <DialogTrigger asChild> diff --git a/lib/tbe/table/tbe-result-dialog.tsx b/lib/tbe/table/tbe-result-dialog.tsx new file mode 100644 index 00000000..59e2f49b --- /dev/null +++ b/lib/tbe/table/tbe-result-dialog.tsx @@ -0,0 +1,208 @@ +"use client" + +import * as React from "react" +import { toast } from "sonner" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Textarea } from "@/components/ui/textarea" +import { Label } from "@/components/ui/label" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { getErrorMessage } from "@/lib/handle-error" +import { saveTbeResult } from "@/lib/rfqs/service" + +// Define the props for the TbeResultDialog component +interface TbeResultDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + tbe: VendorWithTbeFields | null + onRefresh?: () => void +} + +// Define TBE result options +const TBE_RESULT_OPTIONS = [ + { value: "pass", label: "Pass", badgeVariant: "default" }, + { value: "non-pass", label: "Non-Pass", badgeVariant: "destructive" }, + { value: "conditional pass", label: "Conditional Pass", badgeVariant: "secondary" }, +] as const + +type TbeResultOption = typeof TBE_RESULT_OPTIONS[number]["value"] + +export function TbeResultDialog({ + open, + onOpenChange, + tbe, + onRefresh, +}: TbeResultDialogProps) { + // Initialize state for form inputs + const [result, setResult] = React.useState<TbeResultOption | "">("") + const [note, setNote] = React.useState("") + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // Update form values when the tbe prop changes + React.useEffect(() => { + if (tbe) { + setResult((tbe.tbeResult as TbeResultOption) || "") + setNote(tbe.tbeNote || "") + } + }, [tbe]) + + // Reset form when dialog closes + React.useEffect(() => { + if (!open) { + // Small delay to avoid visual glitches when dialog is closing + const timer = setTimeout(() => { + if (!tbe) { + setResult("") + setNote("") + } + }, 300) + return () => clearTimeout(timer) + } + }, [open, tbe]) + + // Handle form submission with server action + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!tbe || !result) return + + setIsSubmitting(true) + + try { + // Call the server action to save the TBE result + const response = await saveTbeResult({ + id: tbe.tbeId ?? 0, // This is the id in the rfq_evaluations table + vendorId: tbe.vendorId, // This is the vendorId in the rfq_evaluations table + result: result, // The selected evaluation result + notes: note, // The evaluation notes + }) + + if (!response.success) { + throw new Error(response.message || "Failed to save TBE result") + } + + // Show success toast + toast.success("TBE result saved successfully") + + // Close the dialog + onOpenChange(false) + + // Refresh the data if refresh callback is provided + if (onRefresh) { + onRefresh() + } + } catch (error) { + // Show error toast + toast.error(`Failed to save: ${getErrorMessage(error)}`) + } finally { + setIsSubmitting(false) + } + } + + // Find the selected result option + const selectedOption = TBE_RESULT_OPTIONS.find(option => option.value === result) + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle className="text-xl font-semibold"> + {tbe?.tbeResult ? "Edit TBE Result" : "Enter TBE Result"} + </DialogTitle> + {tbe && ( + <DialogDescription className="text-sm text-muted-foreground mt-1"> + <div className="flex flex-col gap-1"> + <span> + <strong>Vendor:</strong> {tbe.vendorName} + </span> + <span> + <strong>RFQ Code:</strong> {tbe.rfqCode} + </span> + {tbe.email && ( + <span> + <strong>Email:</strong> {tbe.email} + </span> + )} + </div> + </DialogDescription> + )} + </DialogHeader> + + <form onSubmit={handleSubmit} className="space-y-6 py-2"> + <div className="space-y-2"> + <Label htmlFor="tbe-result" className="text-sm font-medium"> + Evaluation Result + </Label> + <Select + value={result} + onValueChange={(value) => setResult(value as TbeResultOption)} + required + > + <SelectTrigger id="tbe-result" className="w-full"> + <SelectValue placeholder="Select a result" /> + </SelectTrigger> + <SelectContent> + {TBE_RESULT_OPTIONS.map((option) => ( + <SelectItem key={option.value} value={option.value}> + <div className="flex items-center"> + <Badge variant={option.badgeVariant as any} className="mr-2"> + {option.label} + </Badge> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + <div className="space-y-2"> + <Label htmlFor="tbe-note" className="text-sm font-medium"> + Evaluation Note + </Label> + <Textarea + id="tbe-note" + placeholder="Enter evaluation notes..." + value={note} + onChange={(e) => setNote(e.target.value)} + className="min-h-[120px] resize-y" + /> + </div> + + <DialogFooter className="gap-2 sm:gap-0"> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + Cancel + </Button> + <Button + type="submit" + disabled={!result || isSubmitting} + className="min-w-[100px]" + > + {isSubmitting ? "Saving..." : "Save"} + </Button> + </DialogFooter> + </form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/tbe/table/tbe-table-columns.tsx b/lib/tbe/table/tbe-table-columns.tsx index 3b62fe06..8f0de88c 100644 --- a/lib/tbe/table/tbe-table-columns.tsx +++ b/lib/tbe/table/tbe-table-columns.tsx @@ -37,6 +37,8 @@ interface GetColumnsProps { router: NextRouter openCommentSheet: (vendorId: number, rfqId: number) => void openFilesDialog: (tbeId: number, vendorId: number, rfqId: number) => void + openVendorContactsDialog: (vendorId: number, vendor: VendorWithTbeFields) => void // 수정된 시그니처 + } @@ -47,7 +49,8 @@ export function getColumns({ setRowAction, router, openCommentSheet, - openFilesDialog + openFilesDialog, + openVendorContactsDialog }: GetColumnsProps): ColumnDef<VendorWithTbeFields>[] { // ---------------------------------------------------------------- // 1) Select 컬럼 (체크박스) @@ -105,6 +108,84 @@ export function getColumns({ cell: ({ row, getValue }) => { // 1) 필드값 가져오기 const val = getValue() + + if (cfg.id === "vendorName") { + const vendor = row.original; + const vendorId = vendor.vendorId; + + // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 + const handleVendorNameClick = () => { + if (vendorId) { + openVendorContactsDialog(vendorId, vendor); // vendor 전체 객체 전달 + } else { + toast.error("협력업체 ID를 찾을 수 없습니다."); + } + }; + + return ( + <Button + variant="link" + className="p-0 h-auto text-left font-normal justify-start hover:underline" + onClick={handleVendorNameClick} + > + {val as string} + </Button> + ); + } + if (cfg.id === "tbeResult") { + const vendor = row.original; + const tbeResult = vendor.tbeResult; + const filesCount = vendor.files?.length ?? 0; + + // Only show button or link if there are files + if (filesCount > 0) { + // Function to handle clicking on the result + const handleTbeResultClick = () => { + setRowAction({ row, type: "tbeResult" }); + }; + + if (!tbeResult) { + // No result yet, but files exist - show "결과 입력" button + return ( + <Button + variant="outline" + size="sm" + onClick={handleTbeResultClick} + > + 결과 입력 + </Button> + ); + } else { + // Result exists - show as a hyperlink + let badgeVariant: "default" | "outline" | "destructive" | "secondary" = "outline"; + + // Set badge variant based on result + if (tbeResult === "pass") { + badgeVariant = "default"; + } else if (tbeResult === "non-pass") { + badgeVariant = "destructive"; + } else if (tbeResult === "conditional pass") { + badgeVariant = "secondary"; + } + + return ( + <Button + variant="link" + className="p-0 h-auto underline" + onClick={handleTbeResultClick} + > + <Badge variant={badgeVariant}> + {tbeResult} + </Badge> + </Button> + ); + } + } + + // No files available, return empty cell + return null; + } + if (cfg.id === "vendorStatus") { const statusVal = row.original.vendorStatus @@ -222,13 +303,27 @@ const commentsColumn: ColumnDef<VendorWithTbeFields> = { } return ( - <Button variant="ghost" size="sm" className="h-8 w-8 p-0 group relative" onClick={handleClick}> - <MessageSquare className="h-4 w-4" /> + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> {commCount > 0 && ( - <Badge variant="secondary" className="absolute -top-1 -right-1 h-4 min-w-[1rem] text-[0.625rem] p-0 flex items-center justify-center"> + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > {commCount} </Badge> )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> </Button> ) }, diff --git a/lib/tbe/table/tbe-table-toolbar-actions.tsx b/lib/tbe/table/tbe-table-toolbar-actions.tsx index 6a336135..cf6a041e 100644 --- a/lib/tbe/table/tbe-table-toolbar-actions.tsx +++ b/lib/tbe/table/tbe-table-toolbar-actions.tsx @@ -28,19 +28,31 @@ export function VendorsTableToolbarActions({ table,rfqId }: VendorsTableToolbarA fileInputRef.current?.click() } + // 선택된 행이 있는 경우 rfqId 확인 + const uniqueRfqIds = table.getFilteredSelectedRowModel().rows.length > 0 + ? [...new Set(table.getFilteredSelectedRowModel().rows.map(row => row.original.rfqId))] + : []; + + const hasMultipleRfqIds = uniqueRfqIds.length > 1; + + const invitationPossibeVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original) + .filter(vendor => vendor.technicalResponseStatus === null); + }, [table.getFilteredSelectedRowModel().rows]); + return ( <div className="flex items-center gap-2"> - {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + {invitationPossibeVendors.length > 0 && ( <InviteVendorsDialog - vendors={table - .getFilteredSelectedRowModel() - .rows.map((row) => row.original)} - rfqId = {rfqId} + vendors={invitationPossibeVendors} + rfqId={rfqId} onSuccess={() => table.toggleAllRowsSelected(false)} + hasMultipleRfqIds={hasMultipleRfqIds} /> - ) : null} - - + )} <Button variant="outline" size="sm" diff --git a/lib/tbe/table/tbe-table.tsx b/lib/tbe/table/tbe-table.tsx index e67b1d3d..83d601b3 100644 --- a/lib/tbe/table/tbe-table.tsx +++ b/lib/tbe/table/tbe-table.tsx @@ -15,12 +15,14 @@ import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-adv import { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./tbe-table-columns" import { Vendor, vendors } from "@/db/schema/vendors" -import { InviteVendorsDialog } from "./invite-vendors-dialog" import { CommentSheet, TbeComment } from "./comments-sheet" import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" import { TBEFileDialog } from "./file-dialog" import { fetchRfqAttachmentsbyCommentId, getAllTBE } from "@/lib/rfqs/service" import { VendorsTableToolbarActions } from "./tbe-table-toolbar-actions" +import { TbeResultDialog } from "./tbe-result-dialog" +import { toast } from "sonner" +import { VendorContactsDialog } from "./vendor-contact-dialog" interface VendorsTableProps { promises: Promise<[ @@ -39,6 +41,8 @@ export function AllTbeTable({ promises }: VendorsTableProps) { // 댓글 시트 관련 state const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [isLoadingComments, setIsLoadingComments] = React.useState(false) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) const [selectedVendorIdForComments, setSelectedVendorIdForComments] = React.useState<number | null>(null) const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) @@ -49,6 +53,10 @@ export function AllTbeTable({ promises }: VendorsTableProps) { const [selectedTbeIdForFiles, setSelectedTbeIdForFiles] = React.useState<number | null>(null) const [selectedRfqIdForFiles, setSelectedRfqIdForFiles] = React.useState<number | null>(null) + const [isContactDialogOpen, setIsContactDialogOpen] = React.useState(false) + const [selectedVendor, setSelectedVendor] = React.useState<VendorWithTbeFields | null>(null) + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) + // 테이블 리프레시용 const handleRefresh = React.useCallback(() => { router.refresh(); @@ -81,25 +89,33 @@ export function AllTbeTable({ promises }: VendorsTableProps) { // ----------------------------------------------------------- async function openCommentSheet(vendorId: number, rfqId: number) { setInitialComments([]) - + setIsLoadingComments(true) const comments = rowAction?.row.original.comments - if (comments && comments.length > 0) { - const commentWithAttachments: TbeComment[] = await Promise.all( - comments.map(async (c) => { - const attachments = await fetchRfqAttachmentsbyCommentId(c.id) - return { - ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 - attachments, - } - }) - ) - setInitialComments(commentWithAttachments) + try { + if (comments && comments.length > 0) { + const commentWithAttachments: TbeComment[] = await Promise.all( + comments.map(async (c) => { + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + return { + ...c, + commentedBy: 1, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + setInitialComments(commentWithAttachments) + } + + setSelectedVendorIdForComments(vendorId) + setSelectedRfqIdForComments(rfqId) + setCommentSheetOpen(true) + } catch (error) { + console.error("Error loading comments:", error) + toast.error("Failed to load comments") + } finally { + // End loading regardless of success/failure + setIsLoadingComments(false) } - - setSelectedVendorIdForComments(vendorId) - setSelectedRfqIdForComments(rfqId) - setCommentSheetOpen(true) } // ----------------------------------------------------------- @@ -112,6 +128,13 @@ export function AllTbeTable({ promises }: VendorsTableProps) { setIsFileDialogOpen(true) } + const openVendorContactsDialog = (vendorId: number, vendor: VendorWithTbeFields) => { + setSelectedVendorId(vendorId) + setSelectedVendor(vendor) + setIsContactDialogOpen(true) + } + + // ----------------------------------------------------------- // 테이블 컬럼 // ----------------------------------------------------------- @@ -122,6 +145,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) { router, openCommentSheet, // 필요하면 직접 호출 가능 openFilesDialog, + openVendorContactsDialog, }), [setRowAction, router] ) @@ -161,7 +185,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) { enableAdvancedFilter: true, initialState: { sorting: [{ id: "rfqVendorUpdated", desc: true }], - columnPinning: { right: ["actions"] }, + columnPinning: { right: ["files", "comments"] }, }, getRowId: (originalRow) => (`${originalRow.id}${originalRow.rfqId}`), shallow: false, @@ -176,7 +200,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) { filterFields={advancedFilterFields} shallow={false} > - <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} /> + <VendorsTableToolbarActions table={table} rfqId={selectedRfqIdForFiles ?? 0} /> </DataTableAdvancedToolbar> </DataTable> @@ -186,7 +210,8 @@ export function AllTbeTable({ promises }: VendorsTableProps) { open={commentSheetOpen} onOpenChange={setCommentSheetOpen} vendorId={selectedVendorIdForComments ?? 0} - rfqId={selectedRfqIdForComments ?? 0} // ← 여기! + rfqId={selectedRfqIdForComments ?? 0} + isLoading={isLoadingComments} initialComments={initialComments} /> @@ -199,6 +224,20 @@ export function AllTbeTable({ promises }: VendorsTableProps) { rfqId={selectedRfqIdForFiles ?? 0} // ← 여기! onRefresh={handleRefresh} /> + + <TbeResultDialog + open={rowAction?.type === "tbeResult"} + onOpenChange={() => setRowAction(null)} + tbe={rowAction?.row.original ?? null} + /> + + <VendorContactsDialog + isOpen={isContactDialogOpen} + onOpenChange={setIsContactDialogOpen} + vendorId={selectedVendorId} + vendor={selectedVendor} + /> + </> ) }
\ No newline at end of file diff --git a/lib/tbe/table/vendor-contact-dialog.tsx b/lib/tbe/table/vendor-contact-dialog.tsx new file mode 100644 index 00000000..6c96d2ef --- /dev/null +++ b/lib/tbe/table/vendor-contact-dialog.tsx @@ -0,0 +1,71 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { VendorWithTbeFields } from "@/config/vendorTbeColumnsConfig" +import { VendorContactsTable } from "@/lib/rfqs/tbe-table/vendor-contact/vendor-contact-table" + +interface VendorContactsDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null + vendor: VendorWithTbeFields | null +} + +export function VendorContactsDialog({ + isOpen, + onOpenChange, + vendorId, + vendor, +}: VendorContactsDialogProps) { + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> + <DialogHeader> + <div className="flex flex-col space-y-2"> + <DialogTitle>협력업체 연락처</DialogTitle> + {vendor && ( + <div className="flex flex-col space-y-1 mt-2"> + <div className="text-sm text-muted-foreground"> + <span className="font-medium text-foreground">{vendor.vendorName}</span> + {vendor.vendorCode && ( + <span className="ml-2 text-xs text-muted-foreground">({vendor.vendorCode})</span> + )} + </div> + <div className="flex items-center"> + {vendor.vendorStatus && ( + <Badge variant="outline" className="mr-2"> + {vendor.vendorStatus} + </Badge> + )} + {vendor.rfqVendorStatus && ( + <Badge + variant={ + vendor.rfqVendorStatus === "INVITED" ? "default" : + vendor.rfqVendorStatus === "DECLINED" ? "destructive" : + vendor.rfqVendorStatus === "ACCEPTED" ? "secondary" : "outline" + } + > + {vendor.rfqVendorStatus} + </Badge> + )} + </div> + </div> + )} + </div> + </DialogHeader> + {vendorId && ( + <div className="py-4"> + <VendorContactsTable vendorId={vendorId} /> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/users/repository.ts b/lib/users/repository.ts index 78d1668b..3a404bde 100644 --- a/lib/users/repository.ts +++ b/lib/users/repository.ts @@ -111,8 +111,6 @@ export const getOtpByEmailAndCode = async ( code: string ): Promise<Otp | null> => { - console.log(email, code, "db") - const [otp] = await db .select() .from(otps) @@ -123,6 +121,20 @@ export const getOtpByEmailAndCode = async ( return otp ?? null; }; +export const getOtpByEmail = async ( + email: string, +): Promise<User | null> => { + + const [user] = await db + .select() + .from(users) + .where( + eq(users.email, email) + ); + + return user ?? null; +}; + export async function findAllRoles(): Promise<Role[]> { return db.select().from(roles).where(eq(roles.domain ,'evcp')).orderBy(asc(roles.name)); }
\ No newline at end of file diff --git a/lib/users/send-otp.ts b/lib/users/send-otp.ts index 55c08eaf..ecaf19a5 100644 --- a/lib/users/send-otp.ts +++ b/lib/users/send-otp.ts @@ -27,48 +27,49 @@ export async function sendOtpAction(email: string, lng: string) { }; } + /////테스트 임시 // OTP 및 만료 시간 생성 - const otp = Math.floor(100000 + Math.random() * 900000).toString(); - const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료 - const token = jwt.sign( - { - email, - otp, - exp: Math.floor(expires.getTime() / 1000), - }, - process.env.JWT_SECRET! - ); + // const otp = Math.floor(100000 + Math.random() * 900000).toString(); + // const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료 + // const token = jwt.sign( + // { + // email, + // otp, + // exp: Math.floor(expires.getTime() / 1000), + // }, + // process.env.JWT_SECRET! + // ); - // DB에 OTP 추가 - await addNewOtp(email, otp, new Date(), token, expires); + // // DB에 OTP 추가 + // await addNewOtp(email, otp, new Date(), token, expires); - // 이메일에서 사용할 URL 구성 - const verificationUrl = `http://${host}/ko/login?token=${token}`; + // // 이메일에서 사용할 URL 구성 + // const verificationUrl = `http://${host}/ko/login?token=${token}`; - // IP 정보로부터 지역 조회 (ip-api 사용) - const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || ''; - let location = ''; - try { - const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`); - const data = await response.json(); - location = data.city && data.country ? `${data.city}, ${data.country}` : ''; - } catch (error) { - // 위치 조회 실패 시 무시 - } + // // IP 정보로부터 지역 조회 (ip-api 사용) + // const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || ''; + // let location = ''; + // try { + // const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`); + // const data = await response.json(); + // location = data.city && data.country ? `${data.city}, ${data.country}` : ''; + // } catch (error) { + // // 위치 조회 실패 시 무시 + // } - // OTP 이메일 발송 - await sendEmail({ - to: email, - subject: `${otp} - SHI eVCP Sign-in Verification`, - template: 'otp', - context: { - name: user.name, - otp, - verificationUrl, - location, - language: lng, - }, - }); + // // OTP 이메일 발송 + // await sendEmail({ + // to: email, + // subject: `${otp} - SHI eVCP Sign-in Verification`, + // template: 'otp', + // context: { + // name: user.name, + // otp, + // verificationUrl, + // location, + // language: lng, + // }, + // }); // 클라이언트로 반환할 수 있는 값 return { diff --git a/lib/users/service.ts b/lib/users/service.ts index ae97beed..8b2c927e 100644 --- a/lib/users/service.ts +++ b/lib/users/service.ts @@ -202,6 +202,32 @@ export async function findEmailandOtp(email: string, code: string) { } } +export async function findEmailTemp(email: string) { + try { + + // 2) 사용자 정보 추가로 조회 + const userRecord: User | null = await getUserByEmail(email) + if (!userRecord) { + return null + } + + // 3) 필요한 형태로 "통합된 객체"를 반환 + return { + email: userRecord.email, + name: userRecord.name, // DB 에서 가져온 실제 이름 + id: userRecord.id, // user id + imageUrl:userRecord.imageUrl, + companyId:userRecord.companyId, + domain:userRecord.domain + // 기타 필요한 필드... + } + + } catch (error) { + // 에러 처리 + throw new Error('Failed to fetch user & otp') + } +} + export async function updateUserProfileImage(formData: FormData) { // 1) FormData에서 데이터 꺼내기 const file = formData.get("file") as File | null diff --git a/lib/users/verifyOtp.ts b/lib/users/verifyOtp.ts index aa759338..84919024 100644 --- a/lib/users/verifyOtp.ts +++ b/lib/users/verifyOtp.ts @@ -1,5 +1,5 @@ // lib/users/verifyOtp.ts -import { findEmailandOtp } from '@/lib/users/service' +import { findEmailTemp, findEmailandOtp } from '@/lib/users/service' // "email과 code가 맞으면 유저 정보, 아니면 null" 형태로 작성 export async function verifyOtp(email: string, code: string) { @@ -27,6 +27,27 @@ export async function verifyOtp(email: string, code: string) { } } +export async function verifyOtpTemp(email: string) { + // DB에서 email과 code가 맞는지, 만료 안됐는지 검증 + const otpRecord = await findEmailTemp(email) + if (!otpRecord) { + return null + } + + + // 여기서 otpRecord에 유저 정보가 있다고 가정 + // 예: otpRecord.userId, otpRecord.userName, otpRecord.email 등 + // 실제 DB 설계에 맞춰 필드명을 조정하세요. + return { + email: otpRecord.email, + name: otpRecord.name, + id: otpRecord.id, + imageUrl: otpRecord.imageUrl, + companyId: otpRecord.companyId, + domain: otpRecord.domain, + } +} + export async function verifyExternalCredentials(username: string, password: string) { // DB에서 email과 code가 맞는지, 만료 안됐는지 검증 diff --git a/lib/vendor-candidates/service.ts b/lib/vendor-candidates/service.ts index 68971f18..bfeb3090 100644 --- a/lib/vendor-candidates/service.ts +++ b/lib/vendor-candidates/service.ts @@ -1,6 +1,5 @@ "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { vendorCandidates} from "@/db/schema/vendors" import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; import { revalidateTag, unstable_noStore } from "next/cache"; import { filterColumns } from "@/lib/filter-columns"; @@ -10,16 +9,20 @@ import db from "@/db/db"; import { sendEmail } from "../mail/sendEmail"; import { CreateVendorCandidateSchema, createVendorCandidateSchema, GetVendorsCandidateSchema, RemoveCandidatesInput, removeCandidatesSchema, updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "./validations"; import { PgTransaction } from "drizzle-orm/pg-core"; +import { users, vendorCandidateLogs, vendorCandidates, vendorCandidatesWithVendorInfo } from "@/db/schema"; +import { headers } from 'next/headers'; export async function getVendorCandidates(input: GetVendorsCandidateSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage + const fromDate = input.from ? new Date(input.from) : undefined; + const toDate = input.to ? new Date(input.to) : undefined; // 1) Advanced filters const advancedWhere = filterColumns({ - table: vendorCandidates, + table: vendorCandidatesWithVendorInfo, filters: input.filters, joinOperator: input.joinOperator, }) @@ -29,12 +32,16 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { if (input.search) { const s = `%${input.search}%` globalWhere = or( - ilike(vendorCandidates.companyName, s), - ilike(vendorCandidates.contactEmail, s), - ilike(vendorCandidates.contactPhone, s), - ilike(vendorCandidates.country, s), - ilike(vendorCandidates.source, s), - ilike(vendorCandidates.status, s), + ilike(vendorCandidatesWithVendorInfo.companyName, s), + ilike(vendorCandidatesWithVendorInfo.contactEmail, s), + ilike(vendorCandidatesWithVendorInfo.contactPhone, s), + ilike(vendorCandidatesWithVendorInfo.country, s), + ilike(vendorCandidatesWithVendorInfo.source, s), + ilike(vendorCandidatesWithVendorInfo.status, s), + ilike(vendorCandidatesWithVendorInfo.taxId, s), + ilike(vendorCandidatesWithVendorInfo.items, s), + ilike(vendorCandidatesWithVendorInfo.remark, s), + ilike(vendorCandidatesWithVendorInfo.address, s), // etc. ) } @@ -44,6 +51,8 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { const finalWhere = and( advancedWhere, globalWhere, + fromDate ? gte(vendorCandidatesWithVendorInfo.createdAt, fromDate) : undefined, + toDate ? lte(vendorCandidatesWithVendorInfo.createdAt, toDate) : undefined ) @@ -53,17 +62,17 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { input.sort && input.sort.length > 0 ? input.sort.map((item) => item.desc - ? desc(vendorCandidates[item.id]) - : asc(vendorCandidates[item.id]) + ? desc(vendorCandidatesWithVendorInfo[item.id]) + : asc(vendorCandidatesWithVendorInfo[item.id]) ) - : [desc(vendorCandidates.createdAt)] + : [desc(vendorCandidatesWithVendorInfo.createdAt)] // 6) Query & count const { data, total } = await db.transaction(async (tx) => { // a) Select from the view const candidatesData = await tx .select() - .from(vendorCandidates) + .from(vendorCandidatesWithVendorInfo) .where(finalWhere) .orderBy(...orderBy) .offset(offset) @@ -72,7 +81,7 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { // b) Count total const resCount = await tx .select({ count: count() }) - .from(vendorCandidates) + .from(vendorCandidatesWithVendorInfo) .where(finalWhere) return { data: candidatesData, total: resCount[0]?.count } @@ -98,30 +107,48 @@ export async function getVendorCandidates(input: GetVendorsCandidateSchema) { )() } -export async function createVendorCandidate(input: CreateVendorCandidateSchema) { +export async function createVendorCandidate(input: CreateVendorCandidateSchema, userId: number) { try { // Validate input const validated = createVendorCandidateSchema.parse(input); - // Insert into database - const [newCandidate] = await db - .insert(vendorCandidates) - .values({ - companyName: validated.companyName, - contactEmail: validated.contactEmail, - contactPhone: validated.contactPhone || null, - country: validated.country || null, - source: validated.source || null, - status: validated.status, - createdAt: new Date(), - updatedAt: new Date(), - }) - .returning(); + // 트랜잭션으로 데이터 삽입과 로그 기록을 원자적으로 처리 + const result = await db.transaction(async (tx) => { + // Insert into database + const [newCandidate] = await tx + .insert(vendorCandidates) + .values({ + companyName: validated.companyName, + contactEmail: validated.contactEmail, + contactPhone: validated.contactPhone || null, + taxId: validated.taxId || "", + address: validated.address || null, + country: validated.country || null, + source: validated.source || null, + status: validated.status || "COLLECTED", + remark: validated.remark || null, + items: validated.items || "", // items가 필수 필드이므로 빈 문자열이라도 제공 + vendorId: validated.vendorId || null, + updatedAt: new Date(), + }) + .returning(); + + // 로그에 기록 + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: newCandidate.id, + userId: userId, + action: "create", + newStatus: newCandidate.status, + comment: `Created new vendor candidate: ${newCandidate.companyName}` + }); + + return newCandidate; + }); // Invalidate cache revalidateTag("vendor-candidates"); - return { success: true, data: newCandidate }; + return { success: true, data: result }; } catch (error) { console.error("Failed to create vendor candidate:", error); return { success: false, error: getErrorMessage(error) }; @@ -187,60 +214,107 @@ export async function getVendorCandidateCounts() { /** * Update a vendor candidate */ -export async function updateVendorCandidate(input: UpdateVendorCandidateSchema) { +export async function updateVendorCandidate(input: UpdateVendorCandidateSchema, userId: number) { try { // Validate input const validated = updateVendorCandidateSchema.parse(input); // Prepare update data (excluding id) const { id, ...updateData } = validated; + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + const baseUrl = `http://${host}` // Add updatedAt timestamp const dataToUpdate = { ...updateData, updatedAt: new Date(), }; - - // Update database - const [updatedCandidate] = await db - .update(vendorCandidates) - .set(dataToUpdate) - .where(eq(vendorCandidates.id, id)) - .returning(); - - // If status was updated to "INVITED", send email - if (validated.status === "INVITED" && updatedCandidate.contactEmail) { - await sendEmail({ - to: updatedCandidate.contactEmail, - subject: "Invitation to Register as a Vendor", - template: "vendor-invitation", - context: { - companyName: updatedCandidate.companyName, - language: "en", - registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`, - } + + const result = await db.transaction(async (tx) => { + // 현재 데이터 조회 (상태 변경 감지를 위해) + const [existingCandidate] = await tx + .select() + .from(vendorCandidates) + .where(eq(vendorCandidates.id, id)); + + if (!existingCandidate) { + throw new Error("Vendor candidate not found"); + } + + // Update database + const [updatedCandidate] = await tx + .update(vendorCandidates) + .set(dataToUpdate) + .where(eq(vendorCandidates.id, id)) + .returning(); + + // 로그 작성 + const statusChanged = + updateData.status && + existingCandidate.status !== updateData.status; + + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: id, + userId: userId, + action: statusChanged ? "status_change" : "update", + oldStatus: statusChanged ? existingCandidate.status : undefined, + newStatus: statusChanged ? updateData.status : undefined, + comment: statusChanged + ? `Status changed from ${existingCandidate.status} to ${updateData.status}` + : `Updated vendor candidate: ${existingCandidate.companyName}` }); - } + + + + // If status was updated to "INVITED", send email + if (statusChanged && updateData.status === "INVITED" && updatedCandidate.contactEmail) { + await sendEmail({ + to: updatedCandidate.contactEmail, + subject: "Invitation to Register as a Vendor", + template: "vendor-invitation", + context: { + companyName: updatedCandidate.companyName, + language: "en", + registrationLink: `${baseUrl}/en/partners`, + } + }); + + // 이메일 전송 로그 + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: id, + userId: userId, + action: "invite_sent", + comment: `Invitation email sent to ${updatedCandidate.contactEmail}` + }); + } + + return updatedCandidate; + }); + // Invalidate cache revalidateTag("vendor-candidates"); - - return { success: true, data: updatedCandidate }; + + return { success: true, data: result }; } catch (error) { console.error("Failed to update vendor candidate:", error); return { success: false, error: getErrorMessage(error) }; } } -/** - * Update status of multiple vendor candidates at once - */ export async function bulkUpdateVendorCandidateStatus({ ids, - status + status, + userId, + comment }: { ids: number[], - status: "COLLECTED" | "INVITED" | "DISCARDED" + status: "COLLECTED" | "INVITED" | "DISCARDED", + userId: number, + comment?: string }) { try { // Validate inputs @@ -252,50 +326,86 @@ export async function bulkUpdateVendorCandidateStatus({ return { success: false, error: "Invalid status" }; } - // Get current data of candidates (needed for email sending) - const candidatesBeforeUpdate = await db - .select() - .from(vendorCandidates) - .where(inArray(vendorCandidates.id, ids)); - - // Update all records - const updatedCandidates = await db - .update(vendorCandidates) - .set({ - status, - updatedAt: new Date(), - }) - .where(inArray(vendorCandidates.id, ids)) - .returning(); - - // If status is "INVITED", send emails to all updated candidates - if (status === "INVITED") { - const emailPromises = updatedCandidates - .filter(candidate => candidate.contactEmail) - .map(candidate => - sendEmail({ - to: candidate.contactEmail!, - subject: "Invitation to Register as a Vendor", - template: "vendor-invitation", - context: { - companyName: candidate.companyName, - language: "en", - registrationLink: `${process.env.NEXT_PUBLIC_APP_URL}/en/partners`, - } - }) - ); - - // Wait for all emails to be sent - await Promise.all(emailPromises); - } + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const baseUrl = `http://${host}` + + const result = await db.transaction(async (tx) => { + // Get current data of candidates (needed for email sending and logging) + const candidatesBeforeUpdate = await tx + .select() + .from(vendorCandidates) + .where(inArray(vendorCandidates.id, ids)); + + // Update all records + const updatedCandidates = await tx + .update(vendorCandidates) + .set({ + status, + updatedAt: new Date(), + }) + .where(inArray(vendorCandidates.id, ids)) + .returning(); + + // 각 후보자에 대한 로그 생성 + const logPromises = candidatesBeforeUpdate.map(candidate => { + if (candidate.status === status) { + // 상태가 변경되지 않은 경우 로그 생성하지 않음 + return Promise.resolve(); + } + + return tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: candidate.id, + userId: userId, + action: "status_change", + oldStatus: candidate.status, + newStatus: status, + comment: comment || `Bulk status update to ${status}` + }); + }); + + await Promise.all(logPromises); + + // If status is "INVITED", send emails to all updated candidates + if (status === "INVITED") { + const emailPromises = updatedCandidates + .filter(candidate => candidate.contactEmail) + .map(async (candidate) => { + await sendEmail({ + to: candidate.contactEmail!, + subject: "Invitation to Register as a Vendor", + template: "vendor-invitation", + context: { + companyName: candidate.companyName, + language: "en", + registrationLink: `${baseUrl}/en/partners`, + } + }); + + // 이메일 발송 로그 + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: candidate.id, + userId: userId, + action: "invite_sent", + comment: `Invitation email sent to ${candidate.contactEmail}` + }); + }); + + // Wait for all emails to be sent + await Promise.all(emailPromises); + } + + return updatedCandidates; + }); + // Invalidate cache revalidateTag("vendor-candidates"); - - return { - success: true, - data: updatedCandidates, - count: updatedCandidates.length + + return { + success: true, + data: result, + count: result.length }; } catch (error) { console.error("Failed to bulk update vendor candidates:", error); @@ -303,58 +413,111 @@ export async function bulkUpdateVendorCandidateStatus({ } } - - - -/** - * Remove multiple vendor candidates by their IDs - */ -export async function removeCandidates(input: RemoveCandidatesInput) { +// 4. 후보자 삭제 함수 업데이트 +export async function removeCandidates(input: RemoveCandidatesInput, userId: number) { try { // Validate input const validated = removeCandidatesSchema.parse(input); - - // Get candidates before deletion (for logging purposes) - const candidatesBeforeDelete = await db - .select({ - id: vendorCandidates.id, - companyName: vendorCandidates.companyName, - }) - .from(vendorCandidates) - .where(inArray(vendorCandidates.id, validated.ids)); - - // Delete the candidates - const deletedCandidates = await db - .delete(vendorCandidates) - .where(inArray(vendorCandidates.id, validated.ids)) - .returning({ id: vendorCandidates.id }); - + + const result = await db.transaction(async (tx) => { + // Get candidates before deletion (for logging purposes) + const candidatesBeforeDelete = await tx + .select() + .from(vendorCandidates) + .where(inArray(vendorCandidates.id, validated.ids)); + + // 각 삭제될 후보자에 대한 로그 생성 + for (const candidate of candidatesBeforeDelete) { + await tx.insert(vendorCandidateLogs).values({ + vendorCandidateId: candidate.id, + userId: userId, + action: "delete", + oldStatus: candidate.status, + comment: `Deleted vendor candidate: ${candidate.companyName}` + }); + } + + // Delete the candidates + const deletedCandidates = await tx + .delete(vendorCandidates) + .where(inArray(vendorCandidates.id, validated.ids)) + .returning({ id: vendorCandidates.id }); + + return { + deletedCandidates, + candidatesBeforeDelete + }; + }); + // If no candidates were deleted, return an error - if (!deletedCandidates.length) { + if (!result.deletedCandidates.length) { return { success: false, error: "No candidates were found with the provided IDs", }; } - + // Log deletion for audit purposes console.log( - `Deleted ${deletedCandidates.length} vendor candidates:`, - candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`) + `Deleted ${result.deletedCandidates.length} vendor candidates:`, + result.candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`) ); - + // Invalidate cache revalidateTag("vendor-candidates"); revalidateTag("vendor-candidate-status-counts"); revalidateTag("vendor-candidate-total-count"); - + return { success: true, - count: deletedCandidates.length, - deletedIds: deletedCandidates.map(c => c.id), + count: result.deletedCandidates.length, + deletedIds: result.deletedCandidates.map(c => c.id), }; } catch (error) { console.error("Failed to remove vendor candidates:", error); return { success: false, error: getErrorMessage(error) }; } +} + +export interface CandidateLogWithUser { + id: number + vendorCandidateId: number + userId: number + userName: string | null + userEmail: string | null + action: string + oldStatus: string | null + newStatus: string | null + comment: string | null + createdAt: Date +} + +export async function getCandidateLogs(candidateId: number): Promise<CandidateLogWithUser[]> { + try { + const logs = await db + .select({ + // vendor_candidate_logs 필드 + id: vendorCandidateLogs.id, + vendorCandidateId: vendorCandidateLogs.vendorCandidateId, + userId: vendorCandidateLogs.userId, + action: vendorCandidateLogs.action, + oldStatus: vendorCandidateLogs.oldStatus, + newStatus: vendorCandidateLogs.newStatus, + comment: vendorCandidateLogs.comment, + createdAt: vendorCandidateLogs.createdAt, + + // 조인한 users 테이블 필드 + userName: users.name, + userEmail: users.email, + }) + .from(vendorCandidateLogs) + .leftJoin(users, eq(vendorCandidateLogs.userId, users.id)) + .where(eq(vendorCandidateLogs.vendorCandidateId, candidateId)) + .orderBy(desc(vendorCandidateLogs.createdAt)) + + return logs + } catch (error) { + console.error("Failed to fetch candidate logs with user info:", error) + throw error + } }
\ No newline at end of file diff --git a/lib/vendor-candidates/table/add-candidates-dialog.tsx b/lib/vendor-candidates/table/add-candidates-dialog.tsx index db475064..733d3716 100644 --- a/lib/vendor-candidates/table/add-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/add-candidates-dialog.tsx @@ -8,10 +8,13 @@ import i18nIsoCountries from "i18n-iso-countries" import enLocale from "i18n-iso-countries/langs/en.json" import koLocale from "i18n-iso-countries/langs/ko.json" import { cn } from "@/lib/utils" +import { useSession } from "next-auth/react" // next-auth 세션 훅 추가 import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { useToast } from "@/hooks/use-toast" import { Popover, PopoverContent, @@ -36,19 +39,9 @@ import { FormMessage, } from "@/components/ui/form" -// shadcn/ui Select -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { createVendorCandidateSchema, CreateVendorCandidateSchema } from "../validations" import { createVendorCandidate } from "../service" -import { vendorCandidates } from "@/db/schema/vendors" // Register locales for countries i18nIsoCountries.registerLocale(enLocale) @@ -65,34 +58,65 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({ export function AddCandidateDialog() { const [open, setOpen] = React.useState(false) const [isSubmitting, setIsSubmitting] = React.useState(false) + const { toast } = useToast() + const { data: session, status } = useSession() // react-hook-form 세팅 const form = useForm<CreateVendorCandidateSchema>({ resolver: zodResolver(createVendorCandidateSchema), defaultValues: { companyName: "", - contactEmail: "", + contactEmail: "", // 이제 빈 문자열이 허용됨 contactPhone: "", + taxId: "", + address: "", country: "", source: "", - status: "COLLECTED", // Default status set to COLLECTED + items: "", + remark: "", + status: "COLLECTED", }, - }) + }); async function onSubmit(data: CreateVendorCandidateSchema) { setIsSubmitting(true) try { - const result = await createVendorCandidate(data) + // 세션 유효성 검사 + if (!session || !session.user || !session.user.id) { + toast({ + title: "인증 오류", + description: "로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.", + variant: "destructive", + }) + return + } + + // userId 추출 (세션 구조에 따라 조정 필요) + const userId = session.user.id + + const result = await createVendorCandidate(data, Number(userId)) if (result.error) { - alert(`에러: ${result.error}`) + toast({ + title: "오류 발생", + description: result.error, + variant: "destructive", + }) return } // 성공 시 모달 닫고 폼 리셋 + toast({ + title: "등록 완료", + description: "협력업체 후보가 성공적으로 등록되었습니다.", + }) form.reset() setOpen(false) } catch (error) { console.error("Failed to create vendor candidate:", error) - alert("An unexpected error occurred") + toast({ + title: "오류 발생", + description: "예상치 못한 오류가 발생했습니다.", + variant: "destructive", + }) } finally { setIsSubmitting(false) } @@ -114,7 +138,7 @@ export function AddCandidateDialog() { </Button> </DialogTrigger> - <DialogContent className="sm:max-w-[425px]"> + <DialogContent className="sm:max-w-[525px]"> <DialogHeader> <DialogTitle>Create New Vendor Candidate</DialogTitle> <DialogDescription> @@ -124,17 +148,15 @@ export function AddCandidateDialog() { {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)}> - <div className="space-y-4 py-4"> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> {/* Company Name 필드 */} <FormField control={form.control} name="companyName" render={({ field }) => ( <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - Company Name - </FormLabel> + <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Enter company name" @@ -147,15 +169,32 @@ export function AddCandidateDialog() { )} /> + {/* Tax ID 필드 (새로 추가) */} + <FormField + control={form.control} + name="taxId" + render={({ field }) => ( + <FormItem> + <FormLabel>Tax ID</FormLabel> + <FormControl> + <Input + placeholder="Tax identification number" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Contact Email 필드 */} <FormField control={form.control} name="contactEmail" render={({ field }) => ( <FormItem> - <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500"> - Contact Email - </FormLabel> + <FormLabel>Contact Email</FormLabel> <FormControl> <Input placeholder="email@example.com" @@ -188,6 +227,25 @@ export function AddCandidateDialog() { )} /> + {/* Address 필드 */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem className="col-span-full"> + <FormLabel>Address</FormLabel> + <FormControl> + <Input + placeholder="Company address" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Country 필드 */} <FormField control={form.control} @@ -260,7 +318,7 @@ export function AddCandidateDialog() { name="source" render={({ field }) => ( <FormItem> - <FormLabel>Source</FormLabel> + <FormLabel>Source <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Where this candidate was found" @@ -273,37 +331,46 @@ export function AddCandidateDialog() { )} /> - {/* Status 필드 */} - {/* <FormField + + {/* Items 필드 (새로 추가) */} + <FormField control={form.control} - name="status" + name="items" render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <Select - onValueChange={field.onChange} - defaultValue={field.value} - disabled={isSubmitting} - > - <FormControl> - <SelectTrigger> - <SelectValue placeholder="Select status" /> - </SelectTrigger> - </FormControl> - <SelectContent> - <SelectGroup> - {vendorCandidates.status.enumValues.map((status) => ( - <SelectItem key={status} value={status}> - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> + <FormItem className="col-span-full"> + <FormLabel>Items <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Textarea + placeholder="List of items or products this vendor provides" + className="min-h-[80px]" + {...field} + disabled={isSubmitting} + /> + </FormControl> <FormMessage /> </FormItem> )} - /> */} + /> + + {/* Remark 필드 (새로 추가) */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem className="col-span-full"> + <FormLabel>Remarks</FormLabel> + <FormControl> + <Textarea + placeholder="Additional notes or comments" + className="min-h-[80px]" + {...field} + disabled={isSubmitting} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> </div> <DialogFooter> diff --git a/lib/vendor-candidates/table/candidates-table-columns.tsx b/lib/vendor-candidates/table/candidates-table-columns.tsx index dc014d4e..113927cf 100644 --- a/lib/vendor-candidates/table/candidates-table-columns.tsx +++ b/lib/vendor-candidates/table/candidates-table-columns.tsx @@ -7,7 +7,7 @@ import { Ellipsis } from "lucide-react" import { toast } from "sonner" import { getErrorMessage } from "@/lib/handle-error" -import { formatDate } from "@/lib/utils" +import { formatDate, formatDateTime } from "@/lib/utils" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" @@ -24,24 +24,24 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" -import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors" import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils" import { candidateColumnsConfig } from "@/config/candidatesColumnsConfig" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { VendorCandidatesWithVendorInfo } from "@/db/schema" interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidates> | null>> + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>> } /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidates>[] { +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidatesWithVendorInfo>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- - const selectColumn: ColumnDef<VendorCandidates> = { + const selectColumn: ColumnDef<VendorCandidatesWithVendorInfo> = { id: "select", header: ({ table }) => ( <Checkbox @@ -70,48 +70,54 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC // ---------------------------------------------------------------- // 2) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<VendorCandidates> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "delete" })} - > - Delete - <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> - </DropdownMenuItem> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, - } +// "actions" 컬럼 예시 +const actionsColumn: ColumnDef<VendorCandidatesWithVendorInfo> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + 편집 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + 삭제 + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + + {/* 여기서 Log 보기 액션 추가 */} + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "log" })} + > + 감사 로그 보기 + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, +} + // ---------------------------------------------------------------- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidates>[] } - const groupMap: Record<string, ColumnDef<VendorCandidates>[]> = {} + // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidatesWithVendorInfo>[] } + const groupMap: Record<string, ColumnDef<VendorCandidatesWithVendorInfo>[]> = {} candidateColumnsConfig.forEach((cfg) => { // 만약 group가 없으면 "_noGroup" 처리 @@ -122,11 +128,11 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC } // child column 정의 - const childCol: ColumnDef<VendorCandidates> = { + const childCol: ColumnDef<VendorCandidatesWithVendorInfo> = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( - <DataTableColumnHeader column={column} title={cfg.label} /> + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> ), meta: { excelHeader: cfg.excelHeader, @@ -148,9 +154,9 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC } - if (cfg.id === "createdAt") { + if (cfg.id === "createdAt" ||cfg.id === "updatedAt" ) { const dateVal = cell.getValue() as Date - return formatDate(dateVal) + return formatDateTime(dateVal) } // code etc... @@ -164,7 +170,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorC // ---------------------------------------------------------------- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<VendorCandidates>[] = [] + const nestedColumns: ColumnDef<VendorCandidatesWithVendorInfo>[] = [] // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 // 여기서는 그냥 Object.entries 순서 diff --git a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx index 2696292d..baf4a583 100644 --- a/lib/vendor-candidates/table/candidates-table-floating-bar.tsx +++ b/lib/vendor-candidates/table/candidates-table-floating-bar.tsx @@ -30,37 +30,48 @@ import { TooltipTrigger, } from "@/components/ui/tooltip" import { Kbd } from "@/components/kbd" +import { useSession } from "next-auth/react" // next-auth 세션 훅 import { ActionConfirmDialog } from "@/components/ui/action-dialog" -import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors" -import { bulkUpdateVendorCandidateStatus, removeCandidates, updateVendorCandidate } from "../service" +import { VendorCandidatesWithVendorInfo, vendorCandidates } from "@/db/schema/vendors" +import { + bulkUpdateVendorCandidateStatus, + removeCandidates, +} from "../service" +/** + * 테이블 상단/하단에 고정되는 Floating Bar + * 상태 일괄 변경, 초대, 삭제, Export 등을 수행 + */ interface CandidatesTableFloatingBarProps { - table: Table<VendorCandidates> + table: Table<VendorCandidatesWithVendorInfo> } -export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloatingBarProps) { +export function VendorCandidateTableFloatingBar({ + table, +}: CandidatesTableFloatingBarProps) { const rows = table.getFilteredSelectedRowModel().rows + const { data: session, status } = useSession() + // React 18의 startTransition 사용 (isPending으로 트랜지션 상태 확인) const [isPending, startTransition] = React.useTransition() const [action, setAction] = React.useState< "update-status" | "export" | "delete" | "invite" >() const [popoverOpen, setPopoverOpen] = React.useState(false) - // Clear selection on Escape key press + // ESC 키로 selection 해제 React.useEffect(() => { function handleKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { table.toggleAllRowsSelected(false) } } - window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [table]) - // 공용 confirm dialog state + // 공용 Confirm Dialog (ActionConfirmDialog) 제어 const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false) const [confirmProps, setConfirmProps] = React.useState<{ title: string @@ -69,25 +80,41 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati }>({ title: "", description: "", - onConfirm: () => { }, + onConfirm: () => {}, }) - // 1) "삭제" Confirm 열기 + /** + * 1) 삭제 버튼 클릭 시 Confirm Dialog 열기 + */ function handleDeleteConfirm() { setAction("delete") + setConfirmProps({ - title: `Delete ${rows.length} user${rows.length > 1 ? "s" : ""}?`, + title: `Delete ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + }?`, description: "This action cannot be undone.", onConfirm: async () => { startTransition(async () => { - const { error } = await removeCandidates({ - ids: rows.map((row) => row.original.id), - }) + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + + // removeCandidates 호출 시 userId를 넘긴다고 가정 + const { error } = await removeCandidates( + { + ids: rows.map((row) => row.original.id), + }, + userId + ) + if (error) { toast.error(error) return } - toast.success("Users deleted") + toast.success("Candidates deleted successfully") table.toggleAllRowsSelected(false) setConfirmDialogOpen(false) }) @@ -96,43 +123,71 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati setConfirmDialogOpen(true) } - // 2) 상태 업데이트 - function handleSelectStatus(newStatus: VendorCandidates["status"]) { + /** + * 2) 선택된 후보들의 상태 일괄 업데이트 + */ + function handleSelectStatus(newStatus: VendorCandidatesWithVendorInfo["status"]) { setAction("update-status") setConfirmProps({ - title: `Update ${rows.length} candidate${rows.length > 1 ? "s" : ""} with status: ${newStatus}?`, + title: `Update ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + } with status: ${newStatus}?`, description: "This action will override their current status.", onConfirm: async () => { startTransition(async () => { + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + const { error } = await bulkUpdateVendorCandidateStatus({ ids: rows.map((row) => row.original.id), status: newStatus, + userId, + comment: `Bulk status update to ${newStatus}`, }) + if (error) { toast.error(error) return } toast.success("Candidates updated") setConfirmDialogOpen(false) + table.toggleAllRowsSelected(false) }) }, }) setConfirmDialogOpen(true) } - // 3) 초대하기 (INVITED 상태로 바꾸고 이메일 전송) + /** + * 3) 초대하기 (status = "INVITED" + 이메일 발송) + */ function handleInvite() { setAction("invite") setConfirmProps({ - title: `Invite ${rows.length} candidate${rows.length > 1 ? "s" : ""}?`, - description: "This will change their status to INVITED and send invitation emails.", + title: `Invite ${rows.length} candidate${ + rows.length > 1 ? "s" : "" + }?`, + description: + "This will change their status to INVITED and send invitation emails.", onConfirm: async () => { startTransition(async () => { + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + const { error } = await bulkUpdateVendorCandidateStatus({ ids: rows.map((row) => row.original.id), status: "INVITED", + userId, + comment: "Bulk invite action", }) + if (error) { toast.error(error) return @@ -147,166 +202,168 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati } return ( - <Portal > - <div className="fixed inset-x-0 bottom-10 z-50 mx-auto w-fit px-2.5" style={{ bottom: '1.5rem' }}> - <div className="w-full overflow-x-auto"> - <div className="mx-auto flex w-fit items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> - <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> - <span className="whitespace-nowrap text-xs"> - {rows.length} selected - </span> - <Separator orientation="vertical" className="ml-2 mr-1" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="ghost" - size="icon" - className="size-5 hover:border" - onClick={() => table.toggleAllRowsSelected(false)} - > - <X className="size-3.5 shrink-0" aria-hidden="true" /> - </Button> - </TooltipTrigger> - <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> - <p className="mr-2">Clear selection</p> - <Kbd abbrTitle="Escape" variant="outline"> - Esc - </Kbd> - </TooltipContent> - </Tooltip> - </div> - <Separator orientation="vertical" className="hidden h-5 sm:block" /> - <div className="flex items-center gap-1.5"> - {/* 초대하기 버튼 (새로 추가) */} - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="sm" - className="h-7 border" - onClick={handleInvite} - disabled={isPending} - > - {isPending && action === "invite" ? ( - <Loader - className="mr-1 size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <Mail className="mr-1 size-3.5" aria-hidden="true" /> - )} - <span>Invite</span> - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Send invitation emails</p> - </TooltipContent> - </Tooltip> + <> + {/* 선택된 row가 있을 때 표시되는 Floating Bar */} + <div className="flex justify-center w-full my-4"> + <div className="flex items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow"> + {/* 선택된 갯수 표시 + Clear selection 버튼 */} + <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1"> + <span className="whitespace-nowrap text-xs"> + {rows.length} selected + </span> + <Separator orientation="vertical" className="ml-2 mr-1" /> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="ghost" + size="icon" + className="size-5 hover:border" + onClick={() => table.toggleAllRowsSelected(false)} + > + <X className="size-3.5 shrink-0" aria-hidden="true" /> + </Button> + </TooltipTrigger> + <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900"> + <p className="mr-2">Clear selection</p> + <Kbd abbrTitle="Escape" variant="outline"> + Esc + </Kbd> + </TooltipContent> + </Tooltip> + </div> - <Select - onValueChange={(value: VendorCandidates["status"]) => { - handleSelectStatus(value) - }} - > - <Tooltip> - <SelectTrigger asChild> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" - disabled={isPending} - > - {isPending && action === "update-status" ? ( - <Loader - className="size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <CheckCircle2 - className="size-3.5" - aria-hidden="true" - /> - )} - </Button> - </TooltipTrigger> - </SelectTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Update status</p> - </TooltipContent> - </Tooltip> - <SelectContent align="center"> - <SelectGroup> - {vendorCandidates.status.enumValues.map((status) => ( - <SelectItem - key={status} - value={status} - className="capitalize" - > - {status} - </SelectItem> - ))} - </SelectGroup> - </SelectContent> - </Select> + <Separator orientation="vertical" className="hidden h-5 sm:block" /> - <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={() => { - setAction("export") + {/* 우측 액션들: 초대, 상태변경, Export, 삭제 */} + <div className="flex items-center gap-1.5"> + {/* 초대하기 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="sm" + className="h-7 border" + onClick={handleInvite} + disabled={isPending} + > + {isPending && action === "invite" ? ( + <Loader + className="mr-1 size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Mail className="mr-1 size-3.5" aria-hidden="true" /> + )} + <span>Invite</span> + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Send invitation emails</p> + </TooltipContent> + </Tooltip> - startTransition(() => { - exportTableToExcel(table, { - excludeColumns: ["select", "actions"], - onlySelected: true, - }) - }) - }} - disabled={isPending} - > - {isPending && action === "export" ? ( - <Loader - className="size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <Download className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> - <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Export candidates</p> - </TooltipContent> - </Tooltip> - + {/* 상태 업데이트 (Select) */} + <Select + onValueChange={(value: VendorCandidatesWithVendorInfo["status"]) => { + handleSelectStatus(value) + }} + > <Tooltip> - <TooltipTrigger asChild> - <Button - variant="secondary" - size="icon" - className="size-7 border" - onClick={handleDeleteConfirm} - disabled={isPending} - > - {isPending && action === "delete" ? ( - <Loader - className="size-3.5 animate-spin" - aria-hidden="true" - /> - ) : ( - <Trash2 className="size-3.5" aria-hidden="true" /> - )} - </Button> - </TooltipTrigger> + <SelectTrigger asChild> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground" + disabled={isPending} + > + {isPending && action === "update-status" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <CheckCircle2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + </SelectTrigger> <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> - <p>Delete candidates</p> + <p>Update status</p> </TooltipContent> </Tooltip> - </div> + <SelectContent align="center"> + <SelectGroup> + {vendorCandidates.status.enumValues.map((status) => ( + <SelectItem + key={status} + value={status} + className="capitalize" + > + {status} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + + {/* Export 버튼 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={() => { + setAction("export") + startTransition(() => { + exportTableToExcel(table, { + excludeColumns: ["select", "actions"], + onlySelected: true, + }) + }) + }} + disabled={isPending} + > + {isPending && action === "export" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Download className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Export candidates</p> + </TooltipContent> + </Tooltip> + + {/* 삭제 버튼 */} + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="secondary" + size="icon" + className="size-7 border" + onClick={handleDeleteConfirm} + disabled={isPending} + > + {isPending && action === "delete" ? ( + <Loader + className="size-3.5 animate-spin" + aria-hidden="true" + /> + ) : ( + <Trash2 className="size-3.5" aria-hidden="true" /> + )} + </Button> + </TooltipTrigger> + <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900"> + <p>Delete candidates</p> + </TooltipContent> + </Tooltip> </div> </div> </div> @@ -318,7 +375,10 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati title={confirmProps.title} description={confirmProps.description} onConfirm={confirmProps.onConfirm} - isLoading={isPending && (action === "delete" || action === "update-status" || action === "invite")} + isLoading={ + isPending && + (action === "delete" || action === "update-status" || action === "invite") + } confirmLabel={ action === "delete" ? "Delete" @@ -328,10 +388,8 @@ export function VendorCandidateTableFloatingBar({ table }: CandidatesTableFloati ? "Invite" : "Confirm" } - confirmVariant={ - action === "delete" ? "destructive" : "default" - } + confirmVariant={action === "delete" ? "destructive" : "default"} /> - </Portal> + </> ) -}
\ No newline at end of file +} diff --git a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx index a2229a54..17462841 100644 --- a/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx +++ b/lib/vendor-candidates/table/candidates-table-toolbar-actions.tsx @@ -14,15 +14,15 @@ import { } from "@/components/ui/dropdown-menu" import { AddCandidateDialog } from "./add-candidates-dialog" -import { VendorCandidates } from "@/db/schema/vendors" import { DeleteCandidatesDialog } from "./delete-candidates-dialog" import { InviteCandidatesDialog } from "./invite-candidates-dialog" import { ImportVendorCandidatesButton } from "./import-button" import { exportVendorCandidateTemplate } from "./excel-template-download" +import { VendorCandidatesWithVendorInfo } from "@/db/schema/vendors" interface CandidatesTableToolbarActionsProps { - table: Table<VendorCandidates> + table: Table<VendorCandidatesWithVendorInfo> } export function CandidatesTableToolbarActions({ table }: CandidatesTableToolbarActionsProps) { diff --git a/lib/vendor-candidates/table/candidates-table.tsx b/lib/vendor-candidates/table/candidates-table.tsx index 2c01733c..e36649b5 100644 --- a/lib/vendor-candidates/table/candidates-table.tsx +++ b/lib/vendor-candidates/table/candidates-table.tsx @@ -11,17 +11,17 @@ import { toSentenceCase } from "@/lib/utils" 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 { DataTableToolbar } from "@/components/data-table/data-table-toolbar" import { useFeatureFlags } from "./feature-flags-provider" import { getVendorCandidateCounts, getVendorCandidates } from "../service" -import { VendorCandidates, vendorCandidates } from "@/db/schema/vendors" +import { vendorCandidates ,VendorCandidatesWithVendorInfo} from "@/db/schema/vendors" import { VendorCandidateTableFloatingBar } from "./candidates-table-floating-bar" import { getColumns } from "./candidates-table-columns" import { CandidatesTableToolbarActions } from "./candidates-table-toolbar-actions" import { DeleteCandidatesDialog } from "./delete-candidates-dialog" import { UpdateCandidateSheet } from "./update-candidate-sheet" import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils" +import { ViewCandidateLogsDialog } from "./view-candidate_logs-dialog" interface VendorCandidatesTableProps { promises: Promise< @@ -41,7 +41,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { const [rowAction, setRowAction] = - React.useState<DataTableRowAction<VendorCandidates> | null>(null) + React.useState<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>(null) const columns = React.useMemo( () => getColumns({ setRowAction }), @@ -59,7 +59,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. */ - const filterFields: DataTableFilterField<VendorCandidates>[] = [ + const filterFields: DataTableFilterField<VendorCandidatesWithVendorInfo>[] = [ { id: "status", @@ -83,7 +83,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. */ - const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidates>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidatesWithVendorInfo>[] = [ { id: "companyName", label: "Company Name", @@ -109,7 +109,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { label: "Status", type: "multi-select", options: vendorCandidates.status.enumValues.map((status) => ({ - label: toSentenceCase(status), + label: (status), value: status, icon: getCandidateStatusIcon(status), count: statusCounts[status], @@ -118,7 +118,7 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { { id: "createdAt", - label: "Created at", + label: "수집일", type: "date", }, ] @@ -168,6 +168,11 @@ export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) { showTrigger={false} onSuccess={() => rowAction?.row.toggleSelected(false)} /> + <ViewCandidateLogsDialog + open={rowAction?.type === "log"} + onOpenChange={() => setRowAction(null)} + candidateId={rowAction?.row.original?.id ?? null} + /> </> ) } diff --git a/lib/vendor-candidates/table/delete-candidates-dialog.tsx b/lib/vendor-candidates/table/delete-candidates-dialog.tsx index e9fabf76..bc231109 100644 --- a/lib/vendor-candidates/table/delete-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/delete-candidates-dialog.tsx @@ -28,12 +28,13 @@ import { DrawerTrigger, } from "@/components/ui/drawer" -import { VendorCandidates } from "@/db/schema/vendors" import { removeCandidates } from "../service" +import { VendorCandidatesWithVendorInfo } from "@/db/schema" +import { useSession } from "next-auth/react" // next-auth 세션 훅 interface DeleteCandidatesDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { - candidates: Row<VendorCandidates>["original"][] + candidates: Row<VendorCandidatesWithVendorInfo>["original"][] showTrigger?: boolean onSuccess?: () => void } @@ -46,12 +47,21 @@ export function DeleteCandidatesDialog({ }: DeleteCandidatesDialogProps) { const [isDeletePending, startDeleteTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session, status } = useSession() function onDelete() { startDeleteTransition(async () => { + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + const userId = Number(session.user.id) + const { error } = await removeCandidates({ ids: candidates.map((candidate) => candidate.id), - }) + }, userId) if (error) { toast.error(error) diff --git a/lib/vendor-candidates/table/excel-template-download.tsx b/lib/vendor-candidates/table/excel-template-download.tsx index b69ab821..673680db 100644 --- a/lib/vendor-candidates/table/excel-template-download.tsx +++ b/lib/vendor-candidates/table/excel-template-download.tsx @@ -16,10 +16,14 @@ export async function exportVendorCandidateTemplate() { // Define the columns with expected headers const columns = [ { header: "Company Name", key: "companyName", width: 30 }, + { header: "Tax ID", key: "taxId", width: 20 }, { header: "Contact Email", key: "contactEmail", width: 30 }, { header: "Contact Phone", key: "contactPhone", width: 20 }, + { header: "Address", key: "address", width: 40 }, { header: "Country", key: "country", width: 20 }, { header: "Source", key: "source", width: 20 }, + { header: "Items", key: "items", width: 40 }, + { header: "Remark", key: "remark", width: 40 }, { header: "Status", key: "status", width: 15 }, ] @@ -27,7 +31,7 @@ export async function exportVendorCandidateTemplate() { worksheet.columns = columns // Style the header row - const headerRow = worksheet.getRow(1) + const headerRow = worksheet.getRow(2) headerRow.font = { bold: true } headerRow.alignment = { horizontal: "center" } headerRow.eachCell((cell) => { @@ -36,24 +40,39 @@ export async function exportVendorCandidateTemplate() { pattern: "solid", fgColor: { argb: "FFCCCCCC" }, } + + // Mark required fields with a red asterisk + const requiredFields = ["Company Name", "Source", "Items"] + if (requiredFields.includes(cell.value as string)) { + cell.value = `${cell.value} *` + cell.font = { bold: true, color: { argb: "FFFF0000" } } + } }) // Add example data rows const exampleData = [ { companyName: "ABC Corporation", + taxId: "123-45-6789", contactEmail: "contact@abc.com", contactPhone: "+1-123-456-7890", + address: "123 Business Ave, Suite 100, New York, NY 10001", country: "US", source: "Website", + items: "Electronic components, Circuit boards, Sensors", + remark: "Potential supplier for Project X", status: "COLLECTED", }, { companyName: "XYZ Ltd.", + taxId: "GB987654321", contactEmail: "info@xyz.com", contactPhone: "+44-987-654-3210", + address: "45 Industrial Park, London, EC2A 4PX", country: "GB", source: "Referral", + items: "Steel components, Metal frames, Industrial hardware", + remark: "Met at trade show in March", status: "COLLECTED", }, ] @@ -65,8 +84,11 @@ export async function exportVendorCandidateTemplate() { // Add data validation for Status column const statusValues = ["COLLECTED", "INVITED", "DISCARDED"] - for (let i = 2; i <= 100; i++) { // Apply to rows 2-100 - worksheet.getCell(`F${i}`).dataValidation = { + const statusColumn = columns.findIndex(col => col.key === "status") + 1 + const statusColLetter = String.fromCharCode(64 + statusColumn) + + for (let i = 4; i <= 100; i++) { // Apply to rows 4-100 (after example data) + worksheet.getCell(`${statusColLetter}${i}`).dataValidation = { type: 'list', allowBlank: true, formulae: [`"${statusValues.join(',')}"`] @@ -74,11 +96,23 @@ export async function exportVendorCandidateTemplate() { } // Add instructions row - worksheet.insertRow(1, ["Please fill in the data below. Required fields: Company Name, Contact Email"]) - worksheet.mergeCells("A1:F1") + worksheet.insertRow(1, ["Please fill in the data below. Required fields are marked with an asterisk (*): Company Name, Source, Items"]) + worksheet.mergeCells(`A1:${String.fromCharCode(64 + columns.length)}1`) const instructionRow = worksheet.getRow(1) instructionRow.font = { bold: true, color: { argb: "FF0000FF" } } instructionRow.alignment = { horizontal: "center" } + instructionRow.height = 30 + + // Auto-width columns based on content + worksheet.columns.forEach(column => { + if (column.key) { // Check that column.key is defined + const dataMax = Math.max(...worksheet.getColumn(column.key).values + .filter(value => value !== null && value !== undefined) + .map(value => String(value).length) + ) + column.width = Math.max(column.width || 10, dataMax + 2) + } + }) // Download the workbook const buffer = await workbook.xlsx.writeBuffer() diff --git a/lib/vendor-candidates/table/import-button.tsx b/lib/vendor-candidates/table/import-button.tsx index 1a2a4f7c..b1dd43a9 100644 --- a/lib/vendor-candidates/table/import-button.tsx +++ b/lib/vendor-candidates/table/import-button.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button' import { Upload, Loader } from 'lucide-react' import { createVendorCandidate } from '../service' import { Input } from '@/components/ui/input' +import { useSession } from "next-auth/react" // next-auth 세션 훅 추가 interface ImportExcelProps { onSuccess?: () => void @@ -15,24 +16,25 @@ interface ImportExcelProps { export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { const fileInputRef = useRef<HTMLInputElement>(null) const [isImporting, setIsImporting] = React.useState(false) + const { data: session, status } = useSession() // Helper function to get cell value as string const getCellValueAsString = (cell: ExcelJS.Cell): string => { if (!cell || cell.value === undefined || cell.value === null) return ''; - + if (typeof cell.value === 'string') return cell.value.trim(); if (typeof cell.value === 'number') return cell.value.toString(); - + // Handle rich text if (typeof cell.value === 'object' && 'richText' in cell.value) { return cell.value.richText.map((rt: any) => rt.text).join(''); } - + // Handle dates if (cell.value instanceof Date) { return cell.value.toISOString().split('T')[0]; } - + // Fallback return String(cell.value); } @@ -42,55 +44,55 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { if (!file) return setIsImporting(true) - + try { // Read the Excel file using ExcelJS const data = await file.arrayBuffer() const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(data) - + // Get the first worksheet const worksheet = workbook.getWorksheet(1) if (!worksheet) { toast.error("No worksheet found in the spreadsheet") return } - + // Check if there's an instruction row - const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null && - worksheet.getRow(1).getCell(2).value === null; - + const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null && + worksheet.getRow(1).getCell(2).value === null; + // Get header row index (row 2 if there's an instruction row, otherwise row 1) const headerRowIndex = hasInstructionRow ? 2 : 1; - + // Get column headers and their indices const headerRow = worksheet.getRow(headerRowIndex); const headers: Record<number, string> = {}; const columnIndices: Record<string, number> = {}; - + headerRow.eachCell((cell, colNumber) => { const header = getCellValueAsString(cell); headers[colNumber] = header; columnIndices[header] = colNumber; }); - + // Process data rows const rows: any[] = []; const startRow = headerRowIndex + 1; - + for (let i = startRow; i <= worksheet.rowCount; i++) { const row = worksheet.getRow(i); - + // Skip empty rows if (row.cellCount === 0) continue; - + // Check if this is likely an example row - const isExample = i === startRow && worksheet.getRow(i+1).values?.length === 0; + const isExample = i === startRow && worksheet.getRow(i + 1).values?.length === 0; if (isExample) continue; - + const rowData: Record<string, any> = {}; let hasData = false; - + // Map the data using header indices Object.entries(columnIndices).forEach(([header, colIndex]) => { const value = getCellValueAsString(row.getCell(colIndex)); @@ -99,22 +101,22 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { hasData = true; } }); - + if (hasData) { rows.push(rowData); } } - + if (rows.length === 0) { toast.error("No data found in the spreadsheet") setIsImporting(false) return } - + // Process each row let successCount = 0; let errorCount = 0; - + // Create promises for all vendor candidate creation operations const promises = rows.map(async (row) => { try { @@ -123,28 +125,40 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { companyName: String(row['Company Name'] || ''), contactEmail: String(row['Contact Email'] || ''), contactPhone: String(row['Contact Phone'] || ''), + taxId: String(row['Tax ID'] || ''), + address: String(row['Address'] || ''), country: String(row['Country'] || ''), source: String(row['Source'] || ''), + items: String(row['Items'] || ''), + remark: String(row['Remark'] || row['Remarks'] || ''), // Default to COLLECTED if not specified status: (row['Status'] || 'COLLECTED') as "COLLECTED" | "INVITED" | "DISCARDED" }; - + // Validate required fields - if (!candidateData.companyName || !candidateData.contactEmail) { + if (!candidateData.companyName || !candidateData.source || + !candidateData.items) { console.error("Missing required fields", candidateData); errorCount++; return null; } - + + if (!session || !session.user || !session.user.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + + const userId = session.user.id + // Create the vendor candidate - const result = await createVendorCandidate(candidateData); - + const result = await createVendorCandidate(candidateData, Number(userId)) + if (result.error) { console.error(`Failed to import row: ${result.error}`, candidateData); errorCount++; return null; } - + successCount++; return result.data; } catch (error) { @@ -153,10 +167,10 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { return null; } }); - + // Wait for all operations to complete await Promise.all(promises); - + // Show results if (successCount > 0) { toast.success(`Successfully imported ${successCount} vendor candidates`); @@ -168,7 +182,7 @@ export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) { } else if (errorCount > 0) { toast.error(`Failed to import all ${errorCount} rows due to errors`); } - + } catch (error) { console.error("Import error:", error); toast.error("Error importing data. Please check file format."); diff --git a/lib/vendor-candidates/table/invite-candidates-dialog.tsx b/lib/vendor-candidates/table/invite-candidates-dialog.tsx index 366b6f45..45cf13c3 100644 --- a/lib/vendor-candidates/table/invite-candidates-dialog.tsx +++ b/lib/vendor-candidates/table/invite-candidates-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Row } from "@tanstack/react-table" -import { Loader, Mail } from "lucide-react" +import { Loader, Mail, AlertCircle, XCircle } from "lucide-react" import { toast } from "sonner" import { useMediaQuery } from "@/hooks/use-media-query" @@ -27,9 +27,15 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer" +import { + Alert, + AlertTitle, + AlertDescription +} from "@/components/ui/alert" import { VendorCandidates } from "@/db/schema/vendors" import { bulkUpdateVendorCandidateStatus } from "../service" +import { useSession } from "next-auth/react" // next-auth 세션 훅 interface InviteCandidatesDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -46,12 +52,35 @@ export function InviteCandidatesDialog({ }: InviteCandidatesDialogProps) { const [isInvitePending, startInviteTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session, status } = useSession() + + // 후보자를 상태별로 분류 + const discardedCandidates = candidates.filter(candidate => candidate.status === "DISCARDED") + const nonDiscardedCandidates = candidates.filter(candidate => candidate.status !== "DISCARDED") + + // 이메일 유무에 따라 초대 가능한 후보자 분류 (DISCARDED가 아닌 후보자 중에서) + const candidatesWithEmail = nonDiscardedCandidates.filter(candidate => candidate.contactEmail) + const candidatesWithoutEmail = nonDiscardedCandidates.filter(candidate => !candidate.contactEmail) + + // 각 카테고리 수 + const invitableCount = candidatesWithEmail.length + const hasUninvitableCandidates = candidatesWithoutEmail.length > 0 + const hasDiscardedCandidates = discardedCandidates.length > 0 function onInvite() { startInviteTransition(async () => { + // 이메일이 있고 DISCARDED가 아닌 후보자만 상태 업데이트 + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) const { error } = await bulkUpdateVendorCandidateStatus({ - ids: candidates.map((candidate) => candidate.id), + ids: candidatesWithEmail.map((candidate) => candidate.id), status: "INVITED", + userId, + comment: "Bulk invite action", }) if (error) { @@ -60,11 +89,72 @@ export function InviteCandidatesDialog({ } props.onOpenChange?.(false) - toast.success("Invitation emails sent") + + if (invitableCount === 0) { + toast.warning("No invitation sent - no eligible candidates with email addresses") + } else { + let skipMessage = "" + + if (hasUninvitableCandidates && hasDiscardedCandidates) { + skipMessage = ` ${candidatesWithoutEmail.length} candidates without email and ${discardedCandidates.length} discarded candidates were skipped.` + } else if (hasUninvitableCandidates) { + skipMessage = ` ${candidatesWithoutEmail.length} candidates without email were skipped.` + } else if (hasDiscardedCandidates) { + skipMessage = ` ${discardedCandidates.length} discarded candidates were skipped.` + } + + toast.success(`Invitation emails sent to ${invitableCount} candidates.${skipMessage}`) + } + onSuccess?.() }) } + // 초대 버튼 비활성화 조건 + const disableInviteButton = isInvitePending || invitableCount === 0 + + const DialogComponent = ( + <> + <div className="space-y-4"> + {/* 이메일 없는 후보자 알림 */} + {hasUninvitableCandidates && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertTitle>Missing Email Addresses</AlertTitle> + <AlertDescription> + {candidatesWithoutEmail.length} candidate{candidatesWithoutEmail.length > 1 ? 's' : ''} {candidatesWithoutEmail.length > 1 ? 'don\'t' : 'doesn\'t'} have email addresses and won't receive invitations. + </AlertDescription> + </Alert> + )} + + {/* 폐기된 후보자 알림 */} + {hasDiscardedCandidates && ( + <Alert variant="destructive"> + <XCircle className="h-4 w-4" /> + <AlertTitle>Discarded Candidates</AlertTitle> + <AlertDescription> + {discardedCandidates.length} candidate{discardedCandidates.length > 1 ? 's have' : ' has'} been discarded and won't receive invitations. + </AlertDescription> + </Alert> + )} + + <DialogDescription> + {invitableCount > 0 ? ( + <> + This will send invitation emails to{" "} + <span className="font-medium">{invitableCount}</span> + {invitableCount === 1 ? " candidate" : " candidates"} and change their status to INVITED. + </> + ) : ( + <> + No candidates can be invited because none of the selected candidates have valid email addresses or they have been discarded. + </> + )} + </DialogDescription> + </div> + </> + ) + if (isDesktop) { return ( <Dialog {...props}> @@ -79,12 +169,8 @@ export function InviteCandidatesDialog({ <DialogContent> <DialogHeader> <DialogTitle>Send invitations?</DialogTitle> - <DialogDescription> - This will send invitation emails to{" "} - <span className="font-medium">{candidates.length}</span> - {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED. - </DialogDescription> </DialogHeader> + {DialogComponent} <DialogFooter className="gap-2 sm:space-x-0"> <DialogClose asChild> <Button variant="outline">Cancel</Button> @@ -93,7 +179,7 @@ export function InviteCandidatesDialog({ aria-label="Invite selected vendors" variant="default" onClick={onInvite} - disabled={isInvitePending} + disabled={disableInviteButton} > {isInvitePending && ( <Loader @@ -122,12 +208,8 @@ export function InviteCandidatesDialog({ <DrawerContent> <DrawerHeader> <DrawerTitle>Send invitations?</DrawerTitle> - <DrawerDescription> - This will send invitation emails to{" "} - <span className="font-medium">{candidates.length}</span> - {candidates.length === 1 ? " candidate" : " candidates"} and change their status to INVITED. - </DrawerDescription> </DrawerHeader> + {DialogComponent} <DrawerFooter className="gap-2 sm:space-x-0"> <DrawerClose asChild> <Button variant="outline">Cancel</Button> @@ -136,7 +218,7 @@ export function InviteCandidatesDialog({ aria-label="Invite selected vendors" variant="default" onClick={onInvite} - disabled={isInvitePending} + disabled={disableInviteButton} > {isInvitePending && ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> diff --git a/lib/vendor-candidates/table/update-candidate-sheet.tsx b/lib/vendor-candidates/table/update-candidate-sheet.tsx index c475210b..3d278126 100644 --- a/lib/vendor-candidates/table/update-candidate-sheet.tsx +++ b/lib/vendor-candidates/table/update-candidate-sheet.tsx @@ -1,7 +1,6 @@ "use client" import * as React from "react" -import { vendorCandidates, VendorCandidates } from "@/db/schema/vendors" import { zodResolver } from "@hookform/resolvers/zod" import { Check, ChevronsUpDown, Loader } from "lucide-react" import { useForm } from "react-hook-form" @@ -38,6 +37,7 @@ import { SheetTitle, } from "@/components/ui/sheet" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { Popover, PopoverContent, @@ -51,9 +51,11 @@ import { CommandItem, CommandList, } from "@/components/ui/command" +import { useSession } from "next-auth/react" // next-auth 세션 훅 import { updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "../validations" import { updateVendorCandidate } from "../service" +import { vendorCandidates,VendorCandidatesWithVendorInfo} from "@/db/schema" // Register locales for countries i18nIsoCountries.registerLocale(enLocale) @@ -69,47 +71,65 @@ const countryArray = Object.entries(countryMap).map(([code, label]) => ({ interface UpdateCandidateSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - candidate: VendorCandidates | null + candidate: VendorCandidatesWithVendorInfo | null } export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateSheetProps) { const [isUpdatePending, startUpdateTransition] = React.useTransition() + const { data: session, status } = useSession() // Set default values from candidate data when the component receives a new candidate + React.useEffect(() => { if (candidate) { form.reset({ id: candidate.id, companyName: candidate.companyName, - contactEmail: candidate.contactEmail, + taxId: candidate.taxId, + contactEmail: candidate.contactEmail || "", // null을 빈 문자열로 변환 contactPhone: candidate.contactPhone || "", + address: candidate.address || "", country: candidate.country || "", source: candidate.source || "", + items: candidate.items, + remark: candidate.remark || "", status: candidate.status, }) } }, [candidate]) + const form = useForm<UpdateVendorCandidateSchema>({ resolver: zodResolver(updateVendorCandidateSchema), defaultValues: { id: candidate?.id || 0, companyName: candidate?.companyName || "", + taxId: candidate?.taxId || "", contactEmail: candidate?.contactEmail || "", contactPhone: candidate?.contactPhone || "", + address: candidate?.address || "", country: candidate?.country || "", source: candidate?.source || "", + items: candidate?.items || "", + remark: candidate?.remark || "", status: candidate?.status || "COLLECTED", }, }) function onSubmit(input: UpdateVendorCandidateSchema) { startUpdateTransition(async () => { + + if (!session?.user?.id) { + toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.") + return + } + const userId = Number(session.user.id) + if (!candidate) return const { error } = await updateVendorCandidate({ ...input, - }) + }, userId) if (error) { toast.error(error) @@ -124,7 +144,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe return ( <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetContent className="flex flex-col gap-6 sm:max-w-md overflow-y-auto"> <SheetHeader className="text-left"> <SheetTitle>Update Vendor Candidate</SheetTitle> <SheetDescription> @@ -142,7 +162,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe name="companyName" render={({ field }) => ( <FormItem> - <FormLabel>Company Name</FormLabel> + <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Enter company name" @@ -155,6 +175,25 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* Tax ID Field */} + <FormField + control={form.control} + name="taxId" + render={({ field }) => ( + <FormItem> + <FormLabel>Tax ID</FormLabel> + <FormControl> + <Input + placeholder="Enter tax ID" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Contact Email Field */} <FormField control={form.control} @@ -194,6 +233,25 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* Address Field */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem> + <FormLabel>Address</FormLabel> + <FormControl> + <Input + placeholder="Enter company address" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Country Field */} <FormField control={form.control} @@ -266,7 +324,7 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe name="source" render={({ field }) => ( <FormItem> - <FormLabel>Source</FormLabel> + <FormLabel>Source <span className="text-red-500">*</span></FormLabel> <FormControl> <Input placeholder="Where this candidate was found" @@ -279,6 +337,46 @@ export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateShe )} /> + {/* Items Field */} + <FormField + control={form.control} + name="items" + render={({ field }) => ( + <FormItem> + <FormLabel>Items <span className="text-red-500">*</span></FormLabel> + <FormControl> + <Textarea + placeholder="List of items or products this vendor provides" + className="min-h-[80px]" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Remark Field */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem> + <FormLabel>Remark</FormLabel> + <FormControl> + <Textarea + placeholder="Additional notes or comments" + className="min-h-[80px]" + {...field} + disabled={isUpdatePending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + {/* Status Field */} <FormField control={form.control} diff --git a/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx new file mode 100644 index 00000000..6d119bf3 --- /dev/null +++ b/lib/vendor-candidates/table/view-candidate_logs-dialog.tsx @@ -0,0 +1,246 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { ScrollArea } from "@/components/ui/scroll-area" +import { formatDateTime } from "@/lib/utils" +import { CandidateLogWithUser, getCandidateLogs } from "../service" +import { useToast } from "@/hooks/use-toast" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Download, Search, User } from "lucide-react" + +interface ViewCandidateLogsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + candidateId: number | null +} + +export function ViewCandidateLogsDialog({ + open, + onOpenChange, + candidateId, +}: ViewCandidateLogsDialogProps) { + const [logs, setLogs] = React.useState<CandidateLogWithUser[]>([]) + const [filteredLogs, setFilteredLogs] = React.useState<CandidateLogWithUser[]>([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState<string | null>(null) + const [searchQuery, setSearchQuery] = React.useState("") + const [actionFilter, setActionFilter] = React.useState<string>("all") + const { toast } = useToast() + + // Get unique action types for filter dropdown + const actionTypes = React.useMemo(() => { + if (!logs.length) return [] + return Array.from(new Set(logs.map(log => log.action))) + }, [logs]) + + // Fetch logs when dialog opens + React.useEffect(() => { + if (open && candidateId) { + setLoading(true) + setError(null) + getCandidateLogs(candidateId) + .then((res) => { + setLogs(res) + setFilteredLogs(res) + }) + .catch((err) => { + console.error(err) + setError("Failed to load logs. Please try again.") + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load candidate logs", + }) + }) + .finally(() => setLoading(false)) + } else { + // Reset state when dialog closes + setSearchQuery("") + setActionFilter("all") + } + }, [open, candidateId, toast]) + + // Filter logs based on search query and action filter + React.useEffect(() => { + if (!logs.length) return + + let result = [...logs] + + // Apply action filter + if (actionFilter !== "all") { + result = result.filter(log => log.action === actionFilter) + } + + // Apply search filter (case insensitive) + if (searchQuery) { + const query = searchQuery.toLowerCase() + result = result.filter(log => + log.action.toLowerCase().includes(query) || + (log.comment && log.comment.toLowerCase().includes(query)) || + (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) || + (log.newStatus && log.newStatus.toLowerCase().includes(query)) || + (log.userName && log.userName.toLowerCase().includes(query)) || + (log.userEmail && log.userEmail.toLowerCase().includes(query)) + ) + } + + setFilteredLogs(result) + }, [logs, searchQuery, actionFilter]) + + // Export logs as CSV + const exportLogs = () => { + if (!filteredLogs.length) return + + const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"] + const csvContent = [ + headers.join(","), + ...filteredLogs.map(log => [ + `"${log.action}"`, + `"${log.oldStatus || ''}"`, + `"${log.newStatus || ''}"`, + `"${log.comment?.replace(/"/g, '""') || ''}"`, + `"${log.userName || ''}"`, + `"${log.userEmail || ''}"`, + `"${formatDateTime(log.createdAt)}"` + ].join(",")) + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.setAttribute("href", url) + link.setAttribute("download", `candidate-logs-${candidateId}-${new Date().toISOString().split('T')[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + // Render status change with appropriate badge + const renderStatusChange = (oldStatus: string, newStatus: string) => { + return ( + <div className="text-sm flex flex-wrap gap-2 items-center"> + <strong>Status:</strong> + <Badge variant="outline" className="text-xs">{oldStatus}</Badge> + <span>→</span> + <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge> + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <DialogTitle>Audit Logs</DialogTitle> + </DialogHeader> + + {/* Filters and search */} + {/* Filters and search */} + <div className="flex items-center gap-2 mb-4"> + <div className="relative flex-1"> + <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> + <Search className="h-4 w-4 text-muted-foreground" /> + </div> + <Input + placeholder="Search logs..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + <Select + value={actionFilter} + onValueChange={setActionFilter} + > + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Filter by action" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All actions</SelectItem> + {actionTypes.map(action => ( + <SelectItem key={action} value={action}>{action}</SelectItem> + ))} + </SelectContent> + </Select> + + <Button + size="icon" + variant="outline" + onClick={exportLogs} + disabled={filteredLogs.length === 0} + title="Export to CSV" + > + <Download className="h-4 w-4" /> + </Button> + </div> + + <div className="space-y-2"> + {loading && ( + <div className="flex justify-center py-8"> + <p className="text-muted-foreground">Loading logs...</p> + </div> + )} + + {error && !loading && ( + <div className="bg-destructive/10 text-destructive p-3 rounded-md"> + {error} + </div> + )} + + {!loading && !error && filteredLogs.length === 0 && ( + <p className="text-muted-foreground text-center py-8"> + {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."} + </p> + )} + + {!loading && !error && filteredLogs.length > 0 && ( + <> + <div className="text-xs text-muted-foreground mb-2"> + Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'} + {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`} + </div> + <ScrollArea className="max-h-96 space-y-4 pr-4"> + {filteredLogs.map((log) => ( + <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors"> + <div className="flex justify-between items-start mb-2"> + <Badge className="text-xs">{log.action}</Badge> + <div className="text-xs text-muted-foreground"> + {formatDateTime(log.createdAt)} + </div> + </div> + + {log.oldStatus && log.newStatus && ( + <div className="my-2"> + {renderStatusChange(log.oldStatus, log.newStatus)} + </div> + )} + + {log.comment && ( + <div className="my-2 text-sm bg-muted/50 p-2 rounded-md"> + <strong>Comment:</strong> {log.comment} + </div> + )} + + {(log.userName || log.userEmail) && ( + <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground"> + <User className="h-3 w-3 mr-1" /> + {log.userName || "Unknown"} + {log.userEmail && <span className="ml-1">({log.userEmail})</span>} + </div> + )} + </div> + ))} + </ScrollArea> + </> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-candidates/validations.ts b/lib/vendor-candidates/validations.ts index 0abb568e..f42d4d3f 100644 --- a/lib/vendor-candidates/validations.ts +++ b/lib/vendor-candidates/validations.ts @@ -1,4 +1,4 @@ -import { vendorCandidates } from "@/db/schema/vendors" +import { vendorCandidates, vendorCandidatesWithVendorInfo } from "@/db/schema/vendors" import { createSearchParamsCache, parseAsArrayOf, @@ -12,13 +12,14 @@ import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" export const searchParamsCandidateCache = createSearchParamsCache({ // Common flags flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), - + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), // Paging page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), // Sorting - adjusting for vendorInvestigationsView - sort: getSortingStateParser<typeof vendorCandidates.$inferSelect>().withDefault([ + sort: getSortingStateParser<typeof vendorCandidatesWithVendorInfo.$inferSelect>().withDefault([ { id: "createdAt", desc: true }, ]), @@ -53,22 +54,45 @@ export type GetVendorsCandidateSchema = Awaited<ReturnType<typeof searchParamsCa // Updated version of the updateVendorCandidateSchema export const updateVendorCandidateSchema = z.object({ id: z.number(), - companyName: z.string().min(1).max(255).optional(), - contactEmail: z.string().email().max(255).optional(), - contactPhone: z.string().max(50).optional(), - country: z.string().max(100).optional(), - source: z.string().max(100).optional(), - status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).optional(), + // 필수 필드 + companyName: z.string().min(1, "회사명은 필수입니다").max(255), + // null을 명시적으로 처리 + contactEmail: z.union([ + z.string().email("유효한 이메일 형식이 아닙니다").max(255), + z.literal(''), + z.null() + ]).optional().transform(val => val === null ? '' : val), + contactPhone: z.union([z.string().max(50), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + country: z.union([z.string().max(100), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + // 필수 필드 + source: z.string().min(1, "출처는 필수입니다").max(100), + address: z.union([z.string(), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + taxId: z.union([z.string(), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + // 필수 필드 + items: z.string().min(1, "항목은 필수입니다"), + remark: z.union([z.string(), z.literal(''), z.null()]).optional() + .transform(val => val === null ? '' : val), + status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]), updatedAt: z.date().optional().default(() => new Date()), -}); +});; // Create schema for vendor candidates export const createVendorCandidateSchema = z.object({ - companyName: z.string().min(1).max(255), - contactEmail: z.string().email().max(255), + companyName: z.string().min(1, "회사명은 필수입니다").max(255), + // 빈 문자열을 undefined로 변환하여 optional 처리 + contactEmail: z.string().email("유효한 이메일 형식이 아닙니다").or(z.literal('')).optional(), contactPhone: z.string().max(50).optional(), country: z.string().max(100).optional(), - source: z.string().max(100).optional(), + source: z.string().min(1, "출처는 필수입니다").max(100), + address: z.string().optional(), + taxId: z.string().optional(), + items: z.string().min(1, "항목은 필수입니다"), + remark: z.string().optional(), + vendorId: z.number().optional(), status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).default("COLLECTED"), }); diff --git a/lib/vendor-document/service.ts b/lib/vendor-document/service.ts index c0a30808..d81e703c 100644 --- a/lib/vendor-document/service.ts +++ b/lib/vendor-document/service.ts @@ -12,6 +12,7 @@ import { countVendorDocuments, selectVendorDocuments } from "./repository" import path from "path"; import fs from "fs/promises"; import { v4 as uuidv4 } from "uuid" +import { contractItems } from "@/db/schema" /** * 특정 vendorId에 속한 문서 목록 조회 @@ -342,4 +343,109 @@ export async function getStageNamesByDocumentId(documentId: number) { console.error("Error fetching stage names:", error); return []; // 오류 발생시 빈 배열 반환 } +} + + +// Define the return types +export interface Document { + id: number; + docNumber: string; + title: string; +} + +export interface IssueStage { + id: number; + stageName: string; +} + +export interface Revision { + revision: string; +} + +// Server Action: Fetch documents by packageId (contractItems.id) +export async function fetchDocumentsByPackageId(packageId: number): Promise<Document[]> { + try { + // First, find the contractId from contractItems where id = packageId + const contractItemResult = await db.select({ contractId: contractItems.contractId }) + .from(contractItems) + .where(eq(contractItems.id, packageId)) + .limit(1); + + if (!contractItemResult.length) { + return []; + } + + const contractId = contractItemResult[0].contractId; + + // Then, get documents associated with this contractId + const docsResult = await db.select({ + id: documents.id, + docNumber: documents.docNumber, + title: documents.title, + }) + .from(documents) + .where(eq(documents.contractId, contractId)) + .orderBy(documents.docNumber); + + return docsResult; + } catch (error) { + console.error("Error fetching documents:", error); + return []; + } +} + +// Server Action: Fetch stages by documentId +export async function fetchStagesByDocumentId(documentId: number): Promise<IssueStage[]> { + try { + const stagesResult = await db.select({ + id: issueStages.id, + stageName: issueStages.stageName, + }) + .from(issueStages) + .where(eq(issueStages.documentId, documentId)) + .orderBy(issueStages.stageName); + + return stagesResult; + } catch (error) { + console.error("Error fetching stages:", error); + return []; + } +} + +// Server Action: Fetch revisions by documentId and stageName +export async function fetchRevisionsByStageParams( + documentId: number, + stageName: string +): Promise<Revision[]> { + try { + // First, find the issueStageId + const stageResult = await db.select({ id: issueStages.id }) + .from(issueStages) + .where( + and( + eq(issueStages.documentId, documentId), + eq(issueStages.stageName, stageName) + ) + ) + .limit(1); + + if (!stageResult.length) { + return []; + } + + const issueStageId = stageResult[0].id; + + // Then, get revisions for this stage + const revisionsResult = await db.select({ + revision: revisions.revision, + }) + .from(revisions) + .where(eq(revisions.issueStageId, issueStageId)) + .orderBy(revisions.revision); + + return revisionsResult; + } catch (error) { + console.error("Error fetching revisions:", error); + return []; + } }
\ No newline at end of file diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts index b731a95c..e3d03cd4 100644 --- a/lib/vendor-investigation/service.ts +++ b/lib/vendor-investigation/service.ts @@ -12,6 +12,7 @@ import { sendEmail } from "../mail/sendEmail"; import fs from "fs" import path from "path" import { v4 as uuid } from "uuid" +import { vendorsLogs } from "@/db/schema"; export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) { return unstable_cache( @@ -44,7 +45,7 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche const finalWhere = and( advancedWhere, globalWhere, - eq(vendorInvestigationsView.vendorStatus, "PQ_SUBMITTED") + // eq(vendorInvestigationsView.vendorStatus, "PQ_APPROVED") ) @@ -82,6 +83,8 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche // 7) Calculate pageCount const pageCount = Math.ceil(total / input.perPage) + console.log(data,"data") + // Now 'data' already contains JSON arrays of contacts & items // thanks to the subqueries in the view definition! return { data, pageCount } @@ -100,50 +103,84 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche } +/** + * Get existing investigations for a list of vendor IDs + * + * @param vendorIds Array of vendor IDs to check for existing investigations + * @returns Array of investigation data + */ +export async function getExistingInvestigationsForVendors(vendorIds: number[]) { + if (!vendorIds.length) return [] + + try { + // Query the vendorInvestigationsView using the vendorIds + const investigations = await db.query.vendorInvestigations.findMany({ + where: inArray(vendorInvestigationsView.vendorId, vendorIds), + orderBy: [desc(vendorInvestigationsView.investigationCreatedAt)], + }) + + return investigations + } catch (error) { + console.error("Error fetching existing investigations:", error) + return [] + } +} + interface RequestInvestigateVendorsInput { ids: number[] } export async function requestInvestigateVendors({ - ids, -}: RequestInvestigateVendorsInput) { + ids, userId // userId를 추가 +}: RequestInvestigateVendorsInput & { userId: number }) { try { if (!ids || ids.length === 0) { return { error: "No vendor IDs provided." } } - // 1. Create a new investigation row for each vendor - // You could also check if an investigation already exists for each vendor - // before inserting. For now, we’ll assume we always insert new ones. - const newRecords = await db - .insert(vendorInvestigations) - .values( - ids.map((vendorId) => ({ - vendorId - })) - ) - .returning() - - // 2. Optionally, send an email notification - // Adjust recipient, subject, and body as needed. + const result = await db.transaction(async (tx) => { + // 1. Create a new investigation row for each vendor + const newRecords = await tx + .insert(vendorInvestigations) + .values( + ids.map((vendorId) => ({ + vendorId + })) + ) + .returning(); + + // 2. 각 벤더에 대해 로그 기록 + await Promise.all( + ids.map(async (vendorId) => { + await tx.insert(vendorsLogs).values({ + vendorId: vendorId, + userId: userId, + action: "investigation_requested", + comment: "Investigation requested for this vendor", + }); + }) + ); + + return newRecords; + }); + + // 3. 이메일 발송 (트랜잭션 외부에서 실행) await sendEmail({ to: "dujin.kim@dtsolution.io", subject: "New Vendor Investigation(s) Requested", - // This template name could match a Handlebars file like: `investigation-request.hbs` template: "investigation-request", context: { - // For example, if you're translating in Korean: language: "ko", - // Add any data you want to use within the template vendorIds: ids, notes: "Please initiate the planned investigations soon." }, - }) + }); - // 3. Optionally, revalidate any pages that might show updated data - // revalidatePath("/your-vendors-page") // or wherever you list the vendors + // 4. 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-investigations"); - return { data: newRecords, error: null } + return { data: result, error: null } } catch (err: unknown) { const errorMessage = err instanceof Error ? err.message : String(err) return { error: errorMessage } diff --git a/lib/vendor-investigation/table/contract-dialog.tsx b/lib/vendor-investigation/table/contract-dialog.tsx new file mode 100644 index 00000000..28e6963b --- /dev/null +++ b/lib/vendor-investigation/table/contract-dialog.tsx @@ -0,0 +1,85 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Avatar } from "@/components/ui/avatar" +import { ScrollArea } from "@/components/ui/scroll-area" +import { ContactItem } from "@/config/vendorInvestigationsColumnsConfig" + +interface ContactsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + investigationId: number | null + contacts: ContactItem[] +} + +export function ContactsDialog({ + open, + onOpenChange, + investigationId, + contacts, +}: ContactsDialogProps) { + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>Vendor Contacts</DialogTitle> + <DialogDescription> + {contacts.length > 0 + ? `Showing ${contacts.length} contacts for investigation #${investigationId}` + : `No contacts found for investigation #${investigationId}`} + </DialogDescription> + </DialogHeader> + <ScrollArea className="max-h-[60vh] pr-4"> + {contacts.length > 0 ? ( + <div className="space-y-4"> + {contacts.map((contact, index) => ( + <div + key={index} + className="flex items-start gap-4 p-3 rounded-lg border" + > + <Avatar className="w-10 h-10"> + <span>{contact.contactName?.charAt(0) || "C"}</span> + </Avatar> + <div className="flex-1 space-y-1"> + <p className="font-medium">{contact.contactName || "Unnamed"}</p> + {contact.contactEmail && ( + <p className="text-sm text-muted-foreground"> + {contact.contactEmail} + </p> + )} + {contact.contactPhone && ( + <p className="text-sm text-muted-foreground"> + {contact.contactPhone} + </p> + )} + {contact.contactPosition && ( + <p className="text-sm text-muted-foreground"> + Position: {contact.contactPosition} + </p> + )} + </div> + </div> + ))} + </div> + ) : ( + <div className="text-center py-6 text-muted-foreground"> + No contacts available + </div> + )} + </ScrollArea> + <DialogFooter> + <Button onClick={() => onOpenChange(false)}>Close</Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx index fa4e2ab8..56aa7962 100644 --- a/lib/vendor-investigation/table/investigation-table.tsx +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -21,6 +21,8 @@ import { PossibleItem } from "@/config/vendorInvestigationsColumnsConfig" import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet" +import { ItemsDrawer } from "./items-dialog" +import { ContactsDialog } from "./contract-dialog" interface VendorsTableProps { promises: Promise< @@ -71,18 +73,48 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { } as VendorInvestigationsViewWithContacts }) }, [rawResponse.data]) + + console.log(transformedData) const pageCount = rawResponse.pageCount + // Add state for row actions const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorInvestigationsViewWithContacts> | null>(null) + // Add state for contacts dialog + const [contactsDialogOpen, setContactsDialogOpen] = React.useState(false) + const [selectedContacts, setSelectedContacts] = React.useState<ContactItem[]>([]) + const [selectedContactInvestigationId, setSelectedContactInvestigationId] = React.useState<number | null>(null) + + // Add state for items drawer + const [itemsDrawerOpen, setItemsDrawerOpen] = React.useState(false) + const [selectedItems, setSelectedItems] = React.useState<PossibleItem[]>([]) + const [selectedItemInvestigationId, setSelectedItemInvestigationId] = React.useState<number | null>(null) + + // Create handlers for opening the contacts dialog and items drawer + const openContactsModal = React.useCallback((investigationId: number, contacts: ContactItem[]) => { + setSelectedContactInvestigationId(investigationId) + setSelectedContacts(contacts || []) + setContactsDialogOpen(true) + }, []) + + const openItemsDrawer = React.useCallback((investigationId: number, items: PossibleItem[]) => { + setSelectedItemInvestigationId(investigationId) + setSelectedItems(items || []) + setItemsDrawerOpen(true) + }, []) + // Get router const router = useRouter() - // Call getColumns() with router injection + // Call getColumns() with all required functions const columns = React.useMemo( - () => getColumns({ setRowAction }), - [setRowAction, router] + () => getColumns({ + setRowAction, + openContactsModal, + openItemsDrawer + }), + [setRowAction, openContactsModal, openItemsDrawer] ) const filterFields: DataTableFilterField<VendorInvestigationsViewWithContacts>[] = [ @@ -123,11 +155,29 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { <VendorsTableToolbarActions table={table} /> </DataTableAdvancedToolbar> </DataTable> + + {/* Update Investigation Sheet */} <UpdateVendorInvestigationSheet open={rowAction?.type === "update"} onOpenChange={() => setRowAction(null)} investigation={rowAction?.row.original ?? null} /> + + {/* Contacts Dialog */} + <ContactsDialog + open={contactsDialogOpen} + onOpenChange={setContactsDialogOpen} + investigationId={selectedContactInvestigationId} + contacts={selectedContacts} + /> + + {/* Items Drawer */} + <ItemsDrawer + open={itemsDrawerOpen} + onOpenChange={setItemsDrawerOpen} + investigationId={selectedItemInvestigationId} + items={selectedItems} + /> </> ) }
\ No newline at end of file diff --git a/lib/vendor-investigation/table/items-dialog.tsx b/lib/vendor-investigation/table/items-dialog.tsx new file mode 100644 index 00000000..5d010ff4 --- /dev/null +++ b/lib/vendor-investigation/table/items-dialog.tsx @@ -0,0 +1,73 @@ +"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 { ScrollArea } from "@/components/ui/scroll-area" +import { PossibleItem } from "@/config/vendorInvestigationsColumnsConfig" + +interface ItemsDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + investigationId: number | null + items: PossibleItem[] +} + +export function ItemsDrawer({ + open, + onOpenChange, + investigationId, + items, +}: ItemsDrawerProps) { + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="sm:max-w-md"> + <SheetHeader> + <SheetTitle>Possible Items</SheetTitle> + <SheetDescription> + {items.length > 0 + ? `Showing ${items.length} items for investigation #${investigationId}` + : `No items found for investigation #${investigationId}`} + </SheetDescription> + </SheetHeader> + <ScrollArea className="max-h-[70vh] mt-6 pr-4"> + {items.length > 0 ? ( + <div className="space-y-4"> + {items.map((item, index) => ( + <div + key={index} + className="flex flex-col gap-2 p-3 rounded-lg border" + > + <div className="flex justify-between items-start"> + <h4 className="font-medium">{item.itemName || "Unknown Item"}</h4> + {item.itemName && ( + <span className="text-xs bg-muted px-2 py-1 rounded"> + {item.itemCode} + </span> + )} + </div> + + + </div> + ))} + </div> + ) : ( + <div className="text-center py-6 text-muted-foreground"> + No items available + </div> + )} + </ScrollArea> + <SheetFooter className="mt-4"> + <Button onClick={() => onOpenChange(false)}>Close</Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/service.ts b/lib/vendor-rfq-response/service.ts index cba6c414..8f2954d7 100644 --- a/lib/vendor-rfq-response/service.ts +++ b/lib/vendor-rfq-response/service.ts @@ -1,10 +1,14 @@ -import { unstable_cache } from "next/cache"; +'use server' + +import { revalidateTag, unstable_cache } from "next/cache"; import db from "@/db/db"; import { and, desc, eq, inArray, isNull, or, sql } from "drizzle-orm"; -import { rfqAttachments, rfqComments, rfqItems } from "@/db/schema/rfq"; +import { rfqAttachments, rfqComments, rfqItems, vendorResponses } from "@/db/schema/rfq"; import { vendorResponsesView, vendorTechnicalResponses, vendorCommercialResponses, vendorResponseAttachments } from "@/db/schema/rfq"; import { items } from "@/db/schema/items"; import { GetRfqsForVendorsSchema } from "../rfqs/validations"; +import { ItemData } from "./vendor-cbe-table/rfq-items-table/rfq-items-table"; +import * as z from "zod" @@ -27,7 +31,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v ); } - // 벤더 ID 필터링 + // 협력업체 ID 필터링 const mainWhere = and(eq(vendorResponsesView.vendorId, vendorId), globalWhere); // 정렬: 응답 시간순 @@ -75,7 +79,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v .leftJoin(items, eq(rfqItems.itemCode, items.itemCode)) .where(inArray(rfqItems.rfqId, distinctRfqs)); - // 3-B) RFQ 첨부 파일 (벤더용) + // 3-B) RFQ 첨부 파일 (협력업체용) const attachAll = await db .select() .from(rfqAttachments) @@ -101,7 +105,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v ); - // 3-E) 벤더 응답 상세 - 기술 + // 3-E) 협력업체 응답 상세 - 기술 const technicalResponsesAll = await db .select() .from(vendorTechnicalResponses) @@ -112,7 +116,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v ) ); - // 3-F) 벤더 응답 상세 - 상업 + // 3-F) 협력업체 응답 상세 - 상업 const commercialResponsesAll = await db .select() .from(vendorCommercialResponses) @@ -123,7 +127,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v ) ); - // 3-G) 벤더 응답 첨부 파일 + // 3-G) 협력업체 응답 첨부 파일 const responseAttachmentsAll = await db .select() .from(vendorResponseAttachments) @@ -257,7 +261,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v projectCode: row.projectCode, projectName: row.projectName, - // 벤더 정보 + // 협력업체 정보 vendorId: row.vendorId, vendorName: row.vendorName, vendorCode: row.vendorCode, @@ -277,7 +281,7 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v result: row.cbeResult, } : null, - // 벤더 응답 상세 + // 협력업체 응답 상세 technicalResponse: techResponseByResponseId.get(row.responseId) || null, commercialResponse: commResponseByResponseId.get(row.responseId) || null, responseAttachments: respAttachByResponseId.get(row.responseId) || [], @@ -298,4 +302,163 @@ export async function getRfqResponsesForVendor(input: GetRfqsForVendorsSchema, v tags: ["rfqs-vendor", `vendor-${vendorId}`], } )(); +} + + +export async function getItemsByRfqId(rfqId: number): Promise<ResponseType> { + try { + if (!rfqId || isNaN(Number(rfqId))) { + return { + success: false, + error: "Invalid RFQ ID provided", + } + } + + // Query the database to get all items for the given RFQ ID + const items = await db + .select() + .from(rfqItems) + .where(eq(rfqItems.rfqId, rfqId)) + .orderBy(rfqItems.itemCode) + + + return { + success: true, + data: items as ItemData[], + } + } catch (error) { + console.error("Error fetching RFQ items:", error) + + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error occurred when fetching RFQ items", + } + } +} + + +// Define the schema for validation +const commercialResponseSchema = z.object({ + responseId: z.number(), + vendorId: z.number(), // Added vendorId field + responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]), + totalPrice: z.number().optional(), + currency: z.string().default("USD"), + paymentTerms: z.string().optional(), + incoterms: z.string().optional(), + deliveryPeriod: z.string().optional(), + warrantyPeriod: z.string().optional(), + validityPeriod: z.string().optional(), + priceBreakdown: z.string().optional(), + commercialNotes: z.string().optional(), +}) + +type CommercialResponseInput = z.infer<typeof commercialResponseSchema> + +interface ResponseType { + success: boolean + error?: string + data?: any +} + +export async function updateCommercialResponse(input: CommercialResponseInput): Promise<ResponseType> { + try { + // Validate input data + const validated = commercialResponseSchema.parse(input) + + // Check if a commercial response already exists for this responseId + const existingResponse = await db + .select() + .from(vendorCommercialResponses) + .where(eq(vendorCommercialResponses.responseId, validated.responseId)) + .limit(1) + + const now = new Date() + + if (existingResponse.length > 0) { + // Update existing record + await db + .update(vendorCommercialResponses) + .set({ + responseStatus: validated.responseStatus, + totalPrice: validated.totalPrice, + currency: validated.currency, + paymentTerms: validated.paymentTerms, + incoterms: validated.incoterms, + deliveryPeriod: validated.deliveryPeriod, + warrantyPeriod: validated.warrantyPeriod, + validityPeriod: validated.validityPeriod, + priceBreakdown: validated.priceBreakdown, + commercialNotes: validated.commercialNotes, + updatedAt: now, + }) + .where(eq(vendorCommercialResponses.responseId, validated.responseId)) + + } else { + // Return error instead of creating a new record + return { + success: false, + error: "해당 응답 ID에 대한 상업 응답 정보를 찾을 수 없습니다." + } + } + + // Also update the main vendor response status if submitted + if (validated.responseStatus === "SUBMITTED") { + // Get the vendor response + const vendorResponseResult = await db + .select() + .from(vendorResponses) + .where(eq(vendorResponses.id, validated.responseId)) + .limit(1) + + if (vendorResponseResult.length > 0) { + // Update the main response status to RESPONDED + await db + .update(vendorResponses) + .set({ + responseStatus: "RESPONDED", + updatedAt: now, + }) + .where(eq(vendorResponses.id, validated.responseId)) + } + } + + // Use vendorId for revalidateTag + revalidateTag(`cbe-vendor-${validated.vendorId}`) + + return { + success: true, + data: { responseId: validated.responseId } + } + + } catch (error) { + console.error("Error updating commercial response:", error) + + if (error instanceof z.ZodError) { + return { + success: false, + error: "유효하지 않은 데이터가 제공되었습니다." + } + } + + return { + success: false, + error: error instanceof Error ? error.message : "Unknown error occurred" + } + } +} +// Helper function to get responseId from rfqId and vendorId +export async function getCommercialResponseByResponseId(responseId: number): Promise<any | null> { + try { + const response = await db + .select() + .from(vendorCommercialResponses) + .where(eq(vendorCommercialResponses.responseId, responseId)) + .limit(1) + + return response.length > 0 ? response[0] : null + } catch (error) { + console.error("Error getting commercial response:", error) + return null + } }
\ No newline at end of file diff --git a/lib/vendor-rfq-response/types.ts b/lib/vendor-rfq-response/types.ts index 5dadc89b..3f595ebb 100644 --- a/lib/vendor-rfq-response/types.ts +++ b/lib/vendor-rfq-response/types.ts @@ -50,7 +50,7 @@ export interface RfqResponse { projectCode?: string | null; projectName?: string | null; - // 벤더 정보 + // 협력업체 정보 vendorId: number; vendorName: string; vendorCode?: string | null; diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx new file mode 100644 index 00000000..c7be0bf4 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table-columns.tsx @@ -0,0 +1,365 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Download, Loader2, MessageSquare, FileEdit } from "lucide-react" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { useRouter } from "next/navigation" +import { VendorWithCbeFields, vendorResponseCbeColumnsConfig } from "@/config/vendorCbeColumnsConfig" +import { toast } from "sonner" + + +type NextRouter = ReturnType<typeof useRouter> + +interface GetColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<VendorWithCbeFields> | null> + > + router: NextRouter + openCommentSheet: (vendorId: number) => void + handleDownloadCbeFiles: (vendorId: number, rfqId: number) => void + loadingVendors: Record<string, boolean> + openVendorContactsDialog: (rfqId: number, rfq: VendorWithCbeFields) => void + // New prop for handling commercial response + openCommercialResponseSheet: (responseId: number, rfq: VendorWithCbeFields) => void +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ + setRowAction, + router, + openCommentSheet, + handleDownloadCbeFiles, + loadingVendors, + openVendorContactsDialog, + openCommercialResponseSheet +}: GetColumnsProps): ColumnDef<VendorWithCbeFields>[] { + // ---------------------------------------------------------------- + // 1) Select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorWithCbeFields> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 그룹화(Nested) 컬럼 구성 + // ---------------------------------------------------------------- + const groupMap: Record<string, ColumnDef<VendorWithCbeFields>[]> = {} + + vendorResponseCbeColumnsConfig.forEach((cfg) => { + const groupName = cfg.group || "_noGroup" + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // childCol: ColumnDef<VendorWithCbeFields> + const childCol: ColumnDef<VendorWithCbeFields> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + maxSize: 120, + // 셀 렌더링 + cell: ({ row, getValue }) => { + // 1) 필드값 가져오기 + const val = getValue() + + + if (cfg.id === "rfqCode") { + const rfq = row.original; + const rfqId = rfq.rfqId; + + // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 + const handleVendorNameClick = () => { + if (rfqId) { + openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달 + } else { + toast.error("협력업체 ID를 찾을 수 없습니다."); + } + }; + + return ( + <Button + variant="link" + className="p-0 h-auto text-left font-normal justify-start hover:underline" + onClick={handleVendorNameClick} + > + {val as string} + </Button> + ); + } + + // Commercial Response Status에 배지 적용 + if (cfg.id === "commercialResponseStatus") { + const status = val as string; + + if (!status) return <span className="text-muted-foreground">-</span>; + + let variant: "default" | "outline" | "secondary" | "destructive" = "outline"; + + switch (status) { + case "SUBMITTED": + variant = "default"; // Green + break; + case "IN_PROGRESS": + variant = "secondary"; // Orange/Yellow + break; + case "PENDING": + variant = "outline"; // Gray + break; + default: + variant = "outline"; + } + + return ( + <Badge variant={variant} className="capitalize"> + {status.toLowerCase().replace("_", " ")} + </Badge> + ); + } + + // 예) TBE Updated (날짜) + if (cfg.id === "respondedAt" || cfg.id === "rfqDueDate" ) { + const dateVal = val as Date | undefined + if (!dateVal) return null + return formatDate(dateVal) + } + + // 그 외 필드는 기본 값 표시 + return val ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // groupMap → nestedColumns + const nestedColumns: ColumnDef<VendorWithCbeFields>[] = [] + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + nestedColumns.push(...colDefs) + } else { + nestedColumns.push({ + id: groupName, + header: groupName, + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 3) Respond 컬럼 (새로 추가) + // ---------------------------------------------------------------- + const respondColumn: ColumnDef<VendorWithCbeFields> = { + id: "respond", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Response" /> + ), + cell: ({ row }) => { + const vendor = row.original + const responseId = vendor.responseId + + if (!responseId) { + return <div className="text-center text-muted-foreground">-</div> + } + + const handleClick = () => { + openCommercialResponseSheet(responseId, vendor) + } + + // Status에 따라 버튼 variant 변경 + let variant: "default" | "outline" | "ghost" | "secondary" = "default" + let buttonText = "Respond" + + if (vendor.commercialResponseStatus === "SUBMITTED") { + variant = "outline" + buttonText = "Update" + } else if (vendor.commercialResponseStatus === "IN_PROGRESS") { + variant = "secondary" + buttonText = "Continue" + } + + return ( + <Button + variant={variant} + size="sm" + // className="w-20" + onClick={handleClick} + > + <FileEdit className="h-3.5 w-3.5 mr-1" /> + {buttonText} + </Button> + ) + }, + enableSorting: false, + maxSize: 200, + minSize: 115, + } + + // ---------------------------------------------------------------- + // 4) Comments 컬럼 + // ---------------------------------------------------------------- + const commentsColumn: ColumnDef<VendorWithCbeFields> = { + id: "comments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Comments" /> + ), + cell: ({ row }) => { + const vendor = row.original + const commCount = vendor.comments?.length ?? 0 + + function handleClick() { + // rowAction + openCommentSheet + setRowAction({ row, type: "comments" }) + openCommentSheet(vendor.responseId ?? 0) + } + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {commCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {commCount} + </Badge> + )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80 + } + + // ---------------------------------------------------------------- + // 5) 파일 다운로드 컬럼 (개별 로딩 상태 적용) + // ---------------------------------------------------------------- + const downloadColumn: ColumnDef<VendorWithCbeFields> = { + id: "attachDownload", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Attach Download" /> + ), + cell: ({ row }) => { + const vendor = row.original + const vendorId = vendor.vendorId + const rfqId = vendor.rfqId + const files = vendor.files?.length || 0 + + if (!vendorId || !rfqId) { + return <div className="text-center text-muted-foreground">-</div> + } + + // 각 행별로 로딩 상태 확인 (vendorId_rfqId 형식의 키 사용) + const rowKey = `${vendorId}_${rfqId}` + const isRowLoading = loadingVendors[rowKey] === true + + // 템플릿 파일이 없으면 다운로드 버튼 비활성화 + const isDisabled = files <= 0 || isRowLoading + + return ( + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={ + isDisabled + ? undefined + : () => handleDownloadCbeFiles(vendorId, rfqId) + } + aria-label={ + isRowLoading + ? "다운로드 중..." + : files > 0 + ? `CBE 첨부 다운로드 (${files}개)` + : "다운로드할 파일 없음" + } + disabled={isDisabled} + > + {isRowLoading ? ( + <Loader2 className="h-4 w-4 animate-spin" /> + ) : ( + <Download className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + )} + + {/* 파일이 1개 이상인 경우 뱃지로 개수 표시 (로딩 중이 아닐 때만) */} + {!isRowLoading && files > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {files} + </Badge> + )} + + <span className="sr-only"> + {isRowLoading + ? "다운로드 중..." + : files > 0 + ? `CBE 첨부 다운로드 (${files}개)` + : "다운로드할 파일 없음"} + </span> + </Button> + ) + }, + enableSorting: false, + maxSize: 80, + } + + // ---------------------------------------------------------------- + // 6) 최종 컬럼 배열 (respondColumn 추가) + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + respondColumn, // 응답 컬럼 추가 + downloadColumn, + commentsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx new file mode 100644 index 00000000..8477f550 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-cbe-table/cbe-table.tsx @@ -0,0 +1,272 @@ +"use client" + +import * as React from "react" +import { useRouter } from "next/navigation" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} 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 "./cbe-table-columns" +import { + fetchRfqAttachmentsbyCommentId, + getCBEbyVendorId, + getFileFromRfqAttachmentsbyid, + fetchCbeFiles +} from "../../rfqs/service" +import { useSession } from "next-auth/react" +import { CbeComment, CommentSheet } from "./comments-sheet" +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { toast } from "sonner" +import { RfqDeailDialog } from "./rfq-detail-dialog" +import { CommercialResponseSheet } from "./respond-cbe-sheet" + +interface VendorsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getCBEbyVendorId>>, + ] + > +} + +export function CbeVendorTable({ promises }: VendorsTableProps) { + const { data: session } = useSession() + const userVendorId = session?.user?.companyId + const userId = Number(session?.user?.id) + // Suspense로 받아온 데이터 + const [{ data, pageCount }] = React.use(promises) + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithCbeFields> | null>(null) + const [selectedCbeId, setSelectedCbeId] = React.useState<number | null>(null) + + // 개별 협력업체별 로딩 상태를 관리하는 맵 + const [loadingVendors, setLoadingVendors] = React.useState<Record<string, boolean>>({}) + + const router = useRouter() + + // 코멘트 관련 상태 + const [initialComments, setInitialComments] = React.useState<CbeComment[]>([]) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) + const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + + // 상업 응답 관련 상태 + const [commercialResponseSheetOpen, setCommercialResponseSheetOpen] = React.useState(false) + const [selectedResponseId, setSelectedResponseId] = React.useState<number | null>(null) + const [selectedRfq, setSelectedRfq] = React.useState<VendorWithCbeFields | null>(null) + + // RFQ 상세 관련 상태 + const [rfqDetailDialogOpen, setRfqDetailDialogOpen] = React.useState(false) + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) + const [selectedRfqDetail, setSelectedRfqDetail] = React.useState<VendorWithCbeFields | null>(null) + + React.useEffect(() => { + if (rowAction?.type === "comments") { + // rowAction가 새로 세팅된 뒤 여기서 openCommentSheet 실행 + openCommentSheet(Number(rowAction.row.original.responseId)) + } + }, [rowAction]) + + async function openCommentSheet(responseId: number) { + setInitialComments([]) + + const comments = rowAction?.row.original.comments + const rfqId = rowAction?.row.original.rfqId + + if (comments && comments.length > 0) { + const commentWithAttachments: CbeComment[] = await Promise.all( + comments.map(async (c) => { + // 서버 액션을 사용하여 코멘트 첨부 파일 가져오기 + const attachments = await fetchRfqAttachmentsbyCommentId(c.id) + + return { + ...c, + commentedBy: userId, // DB나 API 응답에 있다고 가정 + attachments, + } + }) + ) + + setInitialComments(commentWithAttachments) + } + + if(rfqId) { + setSelectedRfqIdForComments(rfqId) + } + setSelectedCbeId(responseId) + setCommentSheetOpen(true) + } + + // 상업 응답 시트 열기 + function openCommercialResponseSheet(responseId: number, rfq: VendorWithCbeFields) { + setSelectedResponseId(responseId) + setSelectedRfq(rfq) + setCommercialResponseSheetOpen(true) + } + + // RFQ 상세 대화상자 열기 + function openRfqDetailDialog(rfqId: number, rfq: VendorWithCbeFields) { + setSelectedRfqId(rfqId) + setSelectedRfqDetail(rfq) + setRfqDetailDialogOpen(true) + } + + const handleDownloadCbeFiles = React.useCallback( + async (vendorId: number, rfqId: number) => { + // 고유 키 생성: vendorId_rfqId + const rowKey = `${vendorId}_${rfqId}` + + // 해당 협력업체의 로딩 상태만 true로 설정 + setLoadingVendors(prev => ({ + ...prev, + [rowKey]: true + })) + + try { + const { files, error } = await fetchCbeFiles(vendorId, rfqId); + if (error) { + toast.error(error); + return; + } + if (files.length === 0) { + toast.warning("다운로드할 CBE 파일이 없습니다"); + return; + } + // 순차적으로 파일 다운로드 + for (const file of files) { + await downloadFile(file.id); + } + toast.success(`${files.length}개의 CBE 파일이 다운로드되었습니다`); + } catch (error) { + toast.error("CBE 파일을 다운로드하는 데 실패했습니다"); + console.error(error); + } finally { + // 해당 협력업체의 로딩 상태만 false로 되돌림 + setLoadingVendors(prev => ({ + ...prev, + [rowKey]: false + })) + } + }, + [] + ); + + const downloadFile = React.useCallback(async (fileId: number) => { + try { + const { file, error } = await getFileFromRfqAttachmentsbyid(fileId); + if (error || !file) { + throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); + } + + const link = document.createElement("a"); + link.href = `/api/rfq-download?path=${encodeURIComponent(file.filePath)}`; + link.download = file.fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + return true; + } catch (error) { + console.error(error); + return false; + } + }, []); + + // 응답 성공 후 데이터 갱신 + const handleResponseSuccess = React.useCallback(() => { + // 필요한 경우 데이터 다시 가져오기 + router.refresh() + }, [router]); + + // getColumns() 호출 시 필요한 핸들러들 주입 + const columns = React.useMemo( + () => getColumns({ + setRowAction, + router, + openCommentSheet, + handleDownloadCbeFiles, + loadingVendors, + openVendorContactsDialog: openRfqDetailDialog, + openCommercialResponseSheet, + }), + [ + setRowAction, + router, + openCommentSheet, + handleDownloadCbeFiles, + loadingVendors, + openRfqDetailDialog, + openCommercialResponseSheet + ] + ); + + // 필터 필드 정의 + const filterFields: DataTableFilterField<VendorWithCbeFields>[] = [] + const advancedFilterFields: DataTableAdvancedFilterField<VendorWithCbeFields>[] = [ + + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "respondedAt", desc: true }], + columnPinning: { right: ["respond", "comments"] }, // respond 컬럼을 오른쪽에 고정 + }, + getRowId: (originalRow) => String(originalRow.responseId), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </DataTable> + + {/* 코멘트 시트 */} + {commentSheetOpen && selectedRfqIdForComments && selectedCbeId !== null && ( + <CommentSheet + open={commentSheetOpen} + onOpenChange={setCommentSheetOpen} + rfqId={selectedRfqIdForComments} + initialComments={initialComments} + vendorId={userVendorId || 0} + currentUserId={userId || 0} + cbeId={selectedCbeId} + /> + )} + + {/* 상업 응답 시트 */} + {commercialResponseSheetOpen && selectedResponseId !== null && selectedRfq && ( + <CommercialResponseSheet + open={commercialResponseSheetOpen} + onOpenChange={setCommercialResponseSheetOpen} + responseId={selectedResponseId} + rfq={selectedRfq} + onSuccess={handleResponseSuccess} + /> + )} + + {/* RFQ 상세 대화상자 */} + {rfqDetailDialogOpen && selectedRfqId !== null && ( + <RfqDeailDialog + isOpen={rfqDetailDialogOpen} + onOpenChange={setRfqDetailDialogOpen} + rfqId={selectedRfqId} + rfq={selectedRfqDetail} + /> + )} + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx new file mode 100644 index 00000000..40d38145 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-cbe-table/comments-sheet.tsx @@ -0,0 +1,323 @@ +"use client" + +import * as React from "react" +import { useForm, useFieldArray } from "react-hook-form" +import { z } from "zod" +import { zodResolver } from "@hookform/resolvers/zod" +import { Download, X, Loader2 } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { toast } from "sonner" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Textarea } from "@/components/ui/textarea" +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput, +} from "@/components/ui/dropzone" +import { + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from "@/components/ui/table" + +import { formatDate } from "@/lib/utils" +import { createRfqCommentWithAttachments } from "@/lib/rfqs/service" + + +export interface CbeComment { + id: number + commentText: string + commentedBy?: number + commentedByEmail?: string + createdAt?: Date + attachments?: { + id: number + fileName: string + filePath: string + }[] +} + +// 1) props 정의 +interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { + initialComments?: CbeComment[] + currentUserId: number + rfqId: number + tbeId?: number + cbeId?: number + vendorId: number + onCommentsUpdated?: (comments: CbeComment[]) => void + isLoading?: boolean // New prop +} + +// 2) 폼 스키마 +const commentFormSchema = z.object({ + commentText: z.string().min(1, "댓글을 입력하세요."), + newFiles: z.array(z.any()).optional(), // File[] +}) +type CommentFormValues = z.infer<typeof commentFormSchema> + +const MAX_FILE_SIZE = 30e6 // 30MB + +export function CommentSheet({ + rfqId, + vendorId, + initialComments = [], + currentUserId, + tbeId, + cbeId, + onCommentsUpdated, + isLoading = false, // Default to false + ...props +}: CommentSheetProps) { + + + const [comments, setComments] = React.useState<CbeComment[]>(initialComments) + const [isPending, startTransition] = React.useTransition() + + React.useEffect(() => { + setComments(initialComments) + }, [initialComments]) + + const form = useForm<CommentFormValues>({ + resolver: zodResolver(commentFormSchema), + defaultValues: { + commentText: "", + newFiles: [], + }, + }) + + const { fields: newFileFields, append, remove } = useFieldArray({ + control: form.control, + name: "newFiles", + }) + + // (A) 기존 코멘트 렌더링 + function renderExistingComments() { + + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + + if (comments.length === 0) { + return <p className="text-sm text-muted-foreground">No comments yet</p> + } + return ( + <Table> + <TableHeader> + <TableRow> + <TableHead className="w-1/2">Comment</TableHead> + <TableHead>Attachments</TableHead> + <TableHead>Created At</TableHead> + <TableHead>Created By</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {comments.map((c) => ( + <TableRow key={c.id}> + <TableCell>{c.commentText}</TableCell> + <TableCell> + {!c.attachments?.length && ( + <span className="text-sm text-muted-foreground">No files</span> + )} + {c.attachments?.length && ( + <div className="flex flex-col gap-1"> + {c.attachments.map((att) => ( + <div key={att.id} className="flex items-center gap-2"> + <a + href={`/api/rfq-download?path=${encodeURIComponent(att.filePath)}`} + download + target="_blank" + rel="noreferrer" + className="inline-flex items-center gap-1 text-blue-600 underline" + > + <Download className="h-4 w-4" /> + {att.fileName} + </a> + </div> + ))} + </div> + )} + </TableCell> + <TableCell> {c.createdAt ? formatDate(c.createdAt) : "-"}</TableCell> + <TableCell>{c.commentedByEmail ?? "-"}</TableCell> + </TableRow> + ))} + </TableBody> + </Table> + ) + } + + // (B) 파일 드롭 + function handleDropAccepted(files: File[]) { + append(files) + } + + // (C) Submit + async function onSubmit(data: CommentFormValues) { + if (!rfqId) return + startTransition(async () => { + try { + const res = await createRfqCommentWithAttachments({ + rfqId, + vendorId, + commentText: data.commentText, + commentedBy: currentUserId, + evaluationId: null, + cbeId: cbeId, + files: data.newFiles, + }) + + if (!res.ok) { + throw new Error("Failed to create comment") + } + + toast.success("Comment created") + + // 임시로 새 코멘트 추가 + const newComment: CbeComment = { + id: res.commentId, // 서버 응답 + commentText: data.commentText, + commentedBy: currentUserId, + createdAt: res.createdAt, + attachments: + data.newFiles?.map((f) => ({ + id: Math.floor(Math.random() * 1e6), + fileName: f.name, + filePath: "/uploads/" + f.name, + })) || [], + } + setComments((prev) => [...prev, newComment]) + onCommentsUpdated?.([...comments, newComment]) + + form.reset() + } catch (err: any) { + console.error(err) + toast.error("Error: " + err.message) + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg"> + <SheetHeader className="text-left"> + <SheetTitle>Comments</SheetTitle> + <SheetDescription> + 필요시 첨부파일과 함께 문의/코멘트를 남길 수 있습니다. + </SheetDescription> + </SheetHeader> + + <div className="max-h-[300px] overflow-y-auto">{renderExistingComments()}</div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> + <FormField + control={form.control} + name="commentText" + render={({ field }) => ( + <FormItem> + <FormLabel>New Comment</FormLabel> + <FormControl> + <Textarea placeholder="Enter your comment..." {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <Dropzone + maxSize={MAX_FILE_SIZE} + onDropAccepted={handleDropAccepted} + onDropRejected={(rej) => { + toast.error("File rejected: " + (rej[0]?.file?.name || "")) + }} + > + {({ maxSize }) => ( + <DropzoneZone className="flex justify-center"> + <DropzoneInput /> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>Drop to attach files</DropzoneTitle> + <DropzoneDescription> + Max size: {prettyBytes(maxSize || 0)} + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + )} + </Dropzone> + + {newFileFields.length > 0 && ( + <div className="flex flex-col gap-2"> + {newFileFields.map((field, idx) => { + const file = form.getValues(`newFiles.${idx}`) + if (!file) return null + return ( + <div + key={field.id} + className="flex items-center justify-between border rounded p-2" + > + <span className="text-sm"> + {file.name} ({prettyBytes(file.size)}) + </span> + <Button + variant="ghost" + size="icon" + type="button" + onClick={() => remove(idx)} + > + <X className="h-4 w-4" /> + </Button> + </div> + ) + })} + </div> + )} + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isPending}> + {isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx new file mode 100644 index 00000000..8cc4fa6f --- /dev/null +++ b/lib/vendor-rfq-response/vendor-cbe-table/respond-cbe-sheet.tsx @@ -0,0 +1,427 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Textarea } from "@/components/ui/textarea" +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { getCommercialResponseByResponseId, updateCommercialResponse } from "../service" + +// Define schema for form validation (client-side) +const commercialResponseFormSchema = z.object({ + responseStatus: z.enum(["PENDING", "IN_PROGRESS", "SUBMITTED", "REJECTED", "ACCEPTED"]), + totalPrice: z.coerce.number().optional(), + currency: z.string().default("USD"), + paymentTerms: z.string().optional(), + incoterms: z.string().optional(), + deliveryPeriod: z.string().optional(), + warrantyPeriod: z.string().optional(), + validityPeriod: z.string().optional(), + priceBreakdown: z.string().optional(), + commercialNotes: z.string().optional(), +}) + +type CommercialResponseFormInput = z.infer<typeof commercialResponseFormSchema> + +interface CommercialResponseSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + rfq: VendorWithCbeFields | null + responseId: number | null // This is the vendor_responses.id + onSuccess?: () => void +} + +export function CommercialResponseSheet({ + rfq, + responseId, + onSuccess, + ...props +}: CommercialResponseSheetProps) { + const [isSubmitting, startSubmitTransition] = React.useTransition() + const [isLoading, setIsLoading] = React.useState(true) + + const form = useForm<CommercialResponseFormInput>({ + resolver: zodResolver(commercialResponseFormSchema), + defaultValues: { + responseStatus: "PENDING", + totalPrice: undefined, + currency: "USD", + paymentTerms: "", + incoterms: "", + deliveryPeriod: "", + warrantyPeriod: "", + validityPeriod: "", + priceBreakdown: "", + commercialNotes: "", + }, + }) + + // Load existing commercial response data when sheet opens + React.useEffect(() => { + async function loadCommercialResponse() { + if (!responseId) return + + setIsLoading(true) + try { + // Use the helper function to get existing data + const existingResponse = await getCommercialResponseByResponseId(responseId) + + if (existingResponse) { + // If we found existing data, populate the form + form.reset({ + responseStatus: existingResponse.responseStatus, + totalPrice: existingResponse.totalPrice, + currency: existingResponse.currency || "USD", + paymentTerms: existingResponse.paymentTerms || "", + incoterms: existingResponse.incoterms || "", + deliveryPeriod: existingResponse.deliveryPeriod || "", + warrantyPeriod: existingResponse.warrantyPeriod || "", + validityPeriod: existingResponse.validityPeriod || "", + priceBreakdown: existingResponse.priceBreakdown || "", + commercialNotes: existingResponse.commercialNotes || "", + }) + } else if (rfq) { + // If no existing data but we have rfq data with some values already + form.reset({ + responseStatus: rfq.commercialResponseStatus as any || "PENDING", + totalPrice: rfq.totalPrice || undefined, + currency: rfq.currency || "USD", + paymentTerms: rfq.paymentTerms || "", + incoterms: rfq.incoterms || "", + deliveryPeriod: rfq.deliveryPeriod || "", + warrantyPeriod: rfq.warrantyPeriod || "", + validityPeriod: rfq.validityPeriod || "", + priceBreakdown: "", + commercialNotes: "", + }) + } + } catch (error) { + console.error("Failed to load commercial response data:", error) + toast.error("상업 응답 데이터를 불러오는데 실패했습니다") + } finally { + setIsLoading(false) + } + } + + loadCommercialResponse() + }, [responseId, rfq, form]) + + function onSubmit(formData: CommercialResponseFormInput) { + if (!responseId) { + toast.error("응답 ID를 찾을 수 없습니다") + return + } + + if (!rfq?.vendorId) { + toast.error("협력업체 ID를 찾을 수 없습니다") + return + } + + startSubmitTransition(async () => { + try { + // Pass both responseId and vendorId to the server action + const result = await updateCommercialResponse({ + responseId, + vendorId: rfq.vendorId, // Include vendorId for revalidateTag + ...formData, + }) + + if (!result.success) { + toast.error(result.error || "응답 제출 중 오류가 발생했습니다") + return + } + + toast.success("Commercial response successfully submitted") + props.onOpenChange?.(false) + + if (onSuccess) { + onSuccess() + } + } catch (error) { + console.error("Error submitting response:", error) + toast.error("응답 제출 중 오류가 발생했습니다") + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Commercial Response</SheetTitle> + <SheetDescription> + {rfq?.rfqCode && <span className="font-medium">{rfq.rfqCode}</span>} + <div className="mt-1">Please provide your commercial response for this RFQ</div> + </SheetDescription> + </SheetHeader> + + {isLoading ? ( + <div className="flex items-center justify-center py-8"> + <Loader className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) : ( + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4 overflow-y-auto max-h-[calc(100vh-200px)] pr-2" + > + <FormField + control={form.control} + name="responseStatus" + render={({ field }) => ( + <FormItem> + <FormLabel>Response Status</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger className="capitalize"> + <SelectValue placeholder="Select response status" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + <SelectItem value="PENDING">Pending</SelectItem> + <SelectItem value="IN_PROGRESS">In Progress</SelectItem> + <SelectItem value="SUBMITTED">Submitted</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="totalPrice" + render={({ field }) => ( + <FormItem> + <FormLabel>Total Price</FormLabel> + <FormControl> + <Input + type="number" + placeholder="0.00" + {...field} + value={field.value || ''} + onChange={(e) => { + const value = e.target.value === '' ? undefined : parseFloat(e.target.value); + field.onChange(value); + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem> + <FormLabel>Currency</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select currency" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + <SelectItem value="USD">USD</SelectItem> + <SelectItem value="EUR">EUR</SelectItem> + <SelectItem value="GBP">GBP</SelectItem> + <SelectItem value="KRW">KRW</SelectItem> + <SelectItem value="JPY">JPY</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* Other form fields remain the same */} + <FormField + control={form.control} + name="paymentTerms" + render={({ field }) => ( + <FormItem> + <FormLabel>Payment Terms</FormLabel> + <FormControl> + <Input placeholder="e.g. Net 30" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="incoterms" + render={({ field }) => ( + <FormItem> + <FormLabel>Incoterms</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value || ''} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select incoterms" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectGroup> + <SelectItem value="EXW">EXW (Ex Works)</SelectItem> + <SelectItem value="FCA">FCA (Free Carrier)</SelectItem> + <SelectItem value="FOB">FOB (Free On Board)</SelectItem> + <SelectItem value="CIF">CIF (Cost, Insurance & Freight)</SelectItem> + <SelectItem value="DAP">DAP (Delivered At Place)</SelectItem> + <SelectItem value="DDP">DDP (Delivered Duty Paid)</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="deliveryPeriod" + render={({ field }) => ( + <FormItem> + <FormLabel>Delivery Period</FormLabel> + <FormControl> + <Input placeholder="e.g. 4-6 weeks" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="warrantyPeriod" + render={({ field }) => ( + <FormItem> + <FormLabel>Warranty Period</FormLabel> + <FormControl> + <Input placeholder="e.g. 12 months" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="validityPeriod" + render={({ field }) => ( + <FormItem> + <FormLabel>Validity Period</FormLabel> + <FormControl> + <Input placeholder="e.g. 30 days" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="priceBreakdown" + render={({ field }) => ( + <FormItem> + <FormLabel>Price Breakdown (Optional)</FormLabel> + <FormControl> + <Textarea + placeholder="Enter price breakdown details here" + className="min-h-[100px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="commercialNotes" + render={({ field }) => ( + <FormItem> + <FormLabel>Additional Notes (Optional)</FormLabel> + <FormControl> + <Textarea + placeholder="Any additional comments or notes" + className="min-h-[100px]" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-4 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isSubmitting} type="submit"> + {isSubmitting && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Submit Response + </Button> + </SheetFooter> + </form> + </Form> + )} + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx new file mode 100644 index 00000000..e9328641 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-detail-dialog.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { VendorWithCbeFields } from "@/config/vendorCbeColumnsConfig" +import { RfqItemsTable } from "./rfq-items-table/rfq-items-table" +import { formatDateTime } from "@/lib/utils" +import { CalendarClock } from "lucide-react" + +interface RfqDeailDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + rfqId: number | null + rfq: VendorWithCbeFields | null +} + +export function RfqDeailDialog({ + isOpen, + onOpenChange, + rfqId, + rfq, +}: RfqDeailDialogProps) { + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{ maxWidth: 1000, height: 480 }}> + <DialogHeader> + <div className="flex flex-col space-y-2"> + <DialogTitle>프로젝트: {rfq && rfq.projectName}({rfq && rfq.projectCode}) / RFQ: {rfq && rfq.rfqCode} Detail</DialogTitle> + {rfq && ( + <div className="flex flex-col space-y-3 mt-2"> + <div className="text-sm text-muted-foreground"> + <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span> + </div> + + {/* 정보를 두 행으로 나누어 표시 */} + <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center"> + {/* 첫 번째 행: 상태 배지 */} + <div className="flex items-center flex-wrap gap-2"> + {rfq.rfqType && ( + <Badge + variant={ + rfq.rfqType === "BUDGETARY" ? "default" : + rfq.rfqType === "PURCHASE" ? "destructive" : + rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline" + } + > + RFQ 유형: {rfq.rfqType} + </Badge> + )} + + + {rfq.vendorStatus && ( + <Badge variant="outline"> + RFQ 상태: {rfq.rfqStatus} + </Badge> + )} + + </div> + + {/* 두 번째 행: Due Date를 강조 표시 */} + {rfq.rfqDueDate && ( + <div className="flex items-center"> + <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3"> + <CalendarClock className="h-3.5 w-3.5" /> + <span className="font-semibold">Due Date:</span> + <span>{formatDateTime(rfq.rfqDueDate)}</span> + </Badge> + </div> + )} + </div> + </div> + )} + </div> + </DialogHeader> + {rfqId && ( + <div className="py-4"> + <RfqItemsTable rfqId={rfqId} /> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx new file mode 100644 index 00000000..bf4ae709 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table-column.tsx @@ -0,0 +1,62 @@ +"use client" +// Because columns rely on React state/hooks for row actions + +import * as React from "react" +import { ColumnDef, Row } from "@tanstack/react-table" +import { ClientDataTableColumnHeaderSimple } from "@/components/client-data-table/data-table-column-simple-header" +import { formatDate } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { ItemData } from "./rfq-items-table" + + +/** getColumns: return array of ColumnDef for 'vendors' data */ +export function getColumns(): ColumnDef<ItemData>[] { + return [ + + // Vendor Name + { + accessorKey: "itemCode", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Item Code" /> + ), + cell: ({ row }) => row.getValue("itemCode"), + }, + + // Vendor Code + { + accessorKey: "description", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Description" /> + ), + cell: ({ row }) => row.getValue("description"), + }, + + // Status + { + accessorKey: "quantity", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Quantity" /> + ), + cell: ({ row }) => row.getValue("quantity"), + }, + + + // Created At + { + accessorKey: "createdAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Created At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + }, + + // Updated At + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <ClientDataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => formatDate(cell.getValue() as Date), + }, + ] +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx new file mode 100644 index 00000000..c5c67e54 --- /dev/null +++ b/lib/vendor-rfq-response/vendor-cbe-table/rfq-items-table/rfq-items-table.tsx @@ -0,0 +1,86 @@ +'use client' + +import * as React from "react" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { getColumns } from "./rfq-items-table-column" +import { DataTableAdvancedFilterField } from "@/types/table" +import { Loader2 } from "lucide-react" +import { useToast } from "@/hooks/use-toast" +import { getItemsByRfqId } from "../../service" + +export interface ItemData { + id: number + itemCode: string + description: string | null + quantity: number + uom: string | null + createdAt: Date + updatedAt: Date +} + +interface RFQItemsTableProps { + rfqId: number +} + +export function RfqItemsTable({ rfqId }: RFQItemsTableProps) { + const { toast } = useToast() + + const columns = React.useMemo( + () => getColumns(), + [] + ) + + const [rfqItems, setRfqItems] = React.useState<ItemData[]>([]) + const [isLoading, setIsLoading] = React.useState(false) + + React.useEffect(() => { + async function loadItems() { + setIsLoading(true) + try { + // Use the correct function name (camelCase) + const result = await getItemsByRfqId(rfqId) + if (result.success && result.data) { + setRfqItems(result.data as ItemData[]) + } else { + throw new Error(result.error || "Unknown error occurred") + } + } catch (error) { + console.error("RFQ 아이템 로드 오류:", error) + toast({ + title: "Error", + description: "Failed to load RFQ items", + variant: "destructive", + }) + } finally { + setIsLoading(false) + } + } + loadItems() + }, [toast, rfqId]) + + const advancedFilterFields: DataTableAdvancedFilterField<ItemData>[] = [ + { id: "itemCode", label: "Item Code", type: "text" }, + { id: "description", label: "Description", type: "text" }, + { id: "quantity", label: "Quantity", type: "number" }, + { id: "uom", label: "UoM", type: "text" }, + ] + + // If loading, show a flex container that fills the parent and centers the spinner + if (isLoading) { + return ( + <div className="flex h-full w-full items-center justify-center"> + <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> + </div> + ) + } + + // Otherwise, show the table + return ( + <ClientDataTable + data={rfqItems} + columns={columns} + advancedFilterFields={advancedFilterFields} + > + </ClientDataTable> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx index 1eee54f5..e0bf9727 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/comments-sheet.tsx @@ -4,7 +4,7 @@ import * as React from "react" import { useForm, useFieldArray } from "react-hook-form" import { z } from "zod" import { zodResolver } from "@hookform/resolvers/zod" -import { Loader, Download, X } from "lucide-react" +import { Loader, Download, X, Loader2 } from "lucide-react" import prettyBytes from "pretty-bytes" import { toast } from "sonner" @@ -79,6 +79,8 @@ interface CommentSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { vendorId:number /** 댓글 저장 후 갱신용 콜백 (옵션) */ onCommentsUpdated?: (comments: TbeComment[]) => void + isLoading?: boolean // New prop + } // 새 코멘트 작성 폼 스키마 @@ -96,6 +98,7 @@ export function CommentSheet({ initialComments = [], currentUserId, onCommentsUpdated, + isLoading = false, // Default to false ...props }: CommentSheetProps) { const [comments, setComments] = React.useState<TbeComment[]>(initialComments) @@ -125,6 +128,15 @@ export function CommentSheet({ // 간단히 테이블 하나로 표현 // 실제로는 Bubble 형태의 UI, Accordion, Timeline 등 다양하게 구성할 수 있음 function renderExistingComments() { + if (isLoading) { + return ( + <div className="flex justify-center items-center h-32"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + <span className="ml-2 text-sm text-muted-foreground">Loading comments...</span> + </div> + ) + } + if (comments.length === 0) { return <p className="text-sm text-muted-foreground">No comments yet</p> } diff --git a/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx new file mode 100644 index 00000000..2056a48f --- /dev/null +++ b/lib/vendor-rfq-response/vendor-tbe-table/rfq-detail-dialog.tsx @@ -0,0 +1,86 @@ +"use client" + +import * as React from "react" +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Badge } from "@/components/ui/badge" +import { formatDateTime } from "@/lib/utils" +import { CalendarClock } from "lucide-react" +import { RfqItemsTable } from "../vendor-cbe-table/rfq-items-table/rfq-items-table" +import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" + +interface RfqDeailDialogProps { + isOpen: boolean + onOpenChange: (open: boolean) => void + rfqId: number | null + rfq: TbeVendorFields | null +} + +export function RfqDeailDialog({ + isOpen, + onOpenChange, + rfqId, + rfq, +}: RfqDeailDialogProps) { + return ( + <Dialog open={isOpen} onOpenChange={onOpenChange}> + <DialogContent className="max-w-[90wv] sm:max-h-[80vh] overflow-auto" style={{maxWidth:1000, height:480}}> + <DialogHeader> + <div className="flex flex-col space-y-2"> + <DialogTitle>{rfq && rfq.rfqCode} Detail</DialogTitle> + {rfq && ( + <div className="flex flex-col space-y-3 mt-2"> + <div className="text-sm text-muted-foreground"> + <span className="font-medium text-foreground">{rfq.rfqDescription && rfq.rfqDescription}</span> + </div> + + {/* 정보를 두 행으로 나누어 표시 */} + <div className="flex flex-col space-y-2 sm:space-y-0 sm:flex-row sm:justify-between sm:items-center"> + {/* 첫 번째 행: 상태 배지 */} + <div className="flex items-center flex-wrap gap-2"> + {rfq.vendorStatus && ( + <Badge variant="outline"> + {rfq.rfqStatus} + </Badge> + )} + {rfq.rfqType && ( + <Badge + variant={ + rfq.rfqType === "BUDGETARY" ? "default" : + rfq.rfqType === "PURCHASE" ? "destructive" : + rfq.rfqType === "PURCHASE_BUDGETARY" ? "secondary" : "outline" + } + > + {rfq.rfqType} + </Badge> + )} + </div> + + {/* 두 번째 행: Due Date를 강조 표시 */} + {rfq.rfqDueDate && ( + <div className="flex items-center"> + <Badge variant="secondary" className="flex gap-1 text-xs py-1 px-3"> + <CalendarClock className="h-3.5 w-3.5" /> + <span className="font-semibold">Due Date:</span> + <span>{formatDateTime(rfq.rfqDueDate)}</span> + </Badge> + </div> + )} + </div> + </div> + )} + </div> + </DialogHeader> + {rfqId && ( + <div className="py-4"> + <RfqItemsTable rfqId={rfqId} /> + </div> + )} + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx index 7a95d7ed..f664d9a3 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table-columns.tsx @@ -31,6 +31,8 @@ interface GetColumnsProps { openCommentSheet: (vendorId: number) => void handleDownloadTbeTemplate: (tbeId: number, vendorId: number, rfqId: number) => void handleUploadTbeResponse: (tbeId: number, vendorId: number, rfqId: number, vendorResponseId:number) => void + openVendorContactsDialog: (rfqId: number, rfq: TbeVendorFields) => void // 수정된 시그니처 + } /** @@ -42,6 +44,7 @@ export function getColumns({ openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, + openVendorContactsDialog }: GetColumnsProps): ColumnDef<TbeVendorFields>[] { // ---------------------------------------------------------------- // 1) Select 컬럼 (체크박스) @@ -112,7 +115,30 @@ export function getColumns({ ) } - + + if (cfg.id === "rfqCode") { + const rfq = row.original; + const rfqId = rfq.rfqId; + + // 협력업체 이름을 클릭할 수 있는 버튼으로 렌더링 + const handleVendorNameClick = () => { + if (rfqId) { + openVendorContactsDialog(rfqId, rfq); // vendor 전체 객체 전달 + } else { + toast.error("협력업체 ID를 찾을 수 없습니다."); + } + }; + + return ( + <Button + variant="link" + className="p-0 h-auto text-left font-normal justify-start hover:underline" + onClick={handleVendorNameClick} + > + {val as string} + </Button> + ); + } if (cfg.id === "rfqVendorStatus") { const statusVal = row.original.rfqVendorStatus if (!statusVal) return null @@ -173,21 +199,28 @@ export function getColumns({ } return ( - <div> - <Button - variant="ghost" - size="sm" - className="h-8 w-8 p-0 group relative" - onClick={handleClick} - aria-label={commCount > 0 ? `View ${commCount} comments` : "Add comment"} - > - <div className="flex items-center justify-center relative"> - <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - </div> - {commCount > 0 && <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span>} - <span className="sr-only">{commCount > 0 ? `${commCount} Comments` : "Add Comment"}</span> - </Button> - </div> + <Button + variant="ghost" + size="sm" + className="relative h-8 w-8 p-0 group" + onClick={handleClick} + aria-label={ + commCount > 0 ? `View ${commCount} comments` : "No comments" + } + > + <MessageSquare className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> + {commCount > 0 && ( + <Badge + variant="secondary" + className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" + > + {commCount} + </Badge> + )} + <span className="sr-only"> + {commCount > 0 ? `${commCount} Comments` : "No Comments"} + </span> + </Button> ) }, enableSorting: false, diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx index 3450a643..13d5dc64 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbe-table.tsx @@ -7,19 +7,17 @@ import type { DataTableFilterField, DataTableRowAction, } from "@/types/table" - -import { toSentenceCase } from "@/lib/utils" +import { toast } from "sonner" 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 { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./tbe-table-columns" -import { Vendor, vendors } from "@/db/schema/vendors" import { fetchRfqAttachmentsbyCommentId, getTBEforVendor } from "../../rfqs/service" import { CommentSheet, TbeComment } from "./comments-sheet" import { TbeVendorFields } from "@/config/vendorTbeColumnsConfig" import { useTbeFileHandlers } from "./tbeFileHandler" import { useSession } from "next-auth/react" +import { RfqDeailDialog } from "./rfq-detail-dialog" interface VendorsTableProps { promises: Promise< @@ -30,7 +28,6 @@ interface VendorsTableProps { } export function TbeVendorTable({ promises }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() const { data: session } = useSession() const userVendorId = session?.user?.companyId const userId = Number(session?.user?.id) @@ -43,8 +40,20 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { const router = useRouter() const [initialComments, setInitialComments] = React.useState<TbeComment[]>([]) + const [isLoadingComments, setIsLoadingComments] = React.useState(false) + const [commentSheetOpen, setCommentSheetOpen] = React.useState(false) const [selectedRfqIdForComments, setSelectedRfqIdForComments] = React.useState<number | null>(null) + const [isRfqDetailDialogOpen, setIsRfqDetailDialogOpen] = React.useState(false) + + const [selectedRfqId, setSelectedRfqId] = React.useState<number | null>(null) + const [selectedRfq, setSelectedRfq] = React.useState<TbeVendorFields | null>(null) + + const openVendorContactsDialog = (rfqId: number, rfq: TbeVendorFields) => { + setSelectedRfqId(rfqId) + setSelectedRfq(rfq) + setIsRfqDetailDialogOpen(true) + } // TBE 파일 핸들러 훅 사용 const { @@ -62,9 +71,11 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { async function openCommentSheet(vendorId: number) { setInitialComments([]) + setIsLoadingComments(true) const comments = rowAction?.row.original.comments + try { if (comments && comments.length > 0) { const commentWithAttachments: TbeComment[] = await Promise.all( comments.map(async (c) => { @@ -73,18 +84,26 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { return { ...c, - commentedBy: 1, // DB나 API 응답에 있다고 가정 + commentedBy: userId, // DB나 API 응답에 있다고 가정 attachments, } }) ) - + setInitialComments(commentWithAttachments) } setSelectedRfqIdForComments(vendorId) setCommentSheetOpen(true) + + } catch (error) { + console.error("Error loading comments:", error) + toast.error("Failed to load comments") + } finally { + // End loading regardless of success/failure + setIsLoadingComments(false) } +} // getColumns() 호출 시, 필요한 모든 핸들러 함수 주입 const columns = React.useMemo( @@ -94,27 +113,25 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, + openVendorContactsDialog }), - [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse] + [setRowAction, router, openCommentSheet, handleDownloadTbeTemplate, handleUploadTbeResponse, openVendorContactsDialog] ) const filterFields: DataTableFilterField<TbeVendorFields>[] = [] const advancedFilterFields: DataTableAdvancedFilterField<TbeVendorFields>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, - { - id: "vendorStatus", - label: "Vendor Status", - type: "multi-select", - options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), - value: status, - })), - }, + { id: "rfqCode", label: "RFQ Code", type: "text" }, + { id: "projectCode", label: "Project Code", type: "text" }, + { id: "projectName", label: "Project Name", type: "text" }, + { id: "rfqCode", label: "RFQ Code", type: "text" }, + { id: "tbeResult", label: "TBE Result", type: "text" }, + { id: "tbeNote", label: "TBE Note", type: "text" }, + { id: "rfqCode", label: "RFQ Code", type: "text" }, + { id: "hasResponse", label: "Response?", type: "boolean" }, { id: "rfqVendorUpdated", label: "Updated at", type: "date" }, + { id: "dueDate", label: "Project Name", type: "date" }, + ] const { table } = useDataTable({ @@ -150,11 +167,20 @@ export function TbeVendorTable({ promises }: VendorsTableProps) { onOpenChange={setCommentSheetOpen} rfqId={selectedRfqIdForComments} initialComments={initialComments} - vendorId={userVendorId||0} - currentUserId={userId||0} + vendorId={userVendorId || 0} + currentUserId={userId || 0} + isLoading={isLoadingComments} // Pass the loading state + /> )} + <RfqDeailDialog + isOpen={isRfqDetailDialogOpen} + onOpenChange={setIsRfqDetailDialogOpen} + rfqId={selectedRfqId} + rfq={selectedRfq} + /> + {/* TBE 파일 다이얼로그 */} <UploadDialog /> </> diff --git a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx index 4efaee77..a0b6f805 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx +++ b/lib/vendor-rfq-response/vendor-tbe-table/tbeFileHandler.tsx @@ -13,9 +13,9 @@ import { import { Button } from "@/components/ui/button"; import { fetchTbeTemplateFiles, - getTbeTemplateFileInfo, uploadTbeResponseFile, getTbeSubmittedFiles, + getFileFromRfqAttachmentsbyid, } from "../../rfqs/service"; import { Dropzone, @@ -118,7 +118,7 @@ export function useTbeFileHandlers() { // 실제 다운로드 로직 const downloadFile = useCallback(async (fileId: number) => { try { - const { file, error } = await getTbeTemplateFileInfo(fileId); + const { file, error } = await getFileFromRfqAttachmentsbyid(fileId); if (error || !file) { throw new Error(error || "파일 정보를 가져오는 데 실패했습니다"); } diff --git a/lib/vendor-type/repository.ts b/lib/vendor-type/repository.ts new file mode 100644 index 00000000..7e0be35e --- /dev/null +++ b/lib/vendor-type/repository.ts @@ -0,0 +1,130 @@ +import { vendorTypes } from "@/db/schema"; +import { SQL, eq, inArray, sql,asc, desc } from "drizzle-orm"; +import { PgTransaction } from "drizzle-orm/pg-core"; + +/** + * 협력업체 타입 조회 (복잡한 where + order + limit + offset 지원) + */ +export async function selectVendorTypes( + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; + } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select() + .from(vendorTypes) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} +/** + * 전체 협력업체 타입 조회 + */ +export async function findAllVendorTypes(tx: any) { + return tx.select() + .from(vendorTypes) + .orderBy(vendorTypes.nameKo); +} + +/** + * 협력업체 타입 개수 카운트 + */ +export async function countVendorTypes(tx: any, where?: SQL<unknown> | undefined) { + const result = await tx + .select({ count: sql`count(*)` }) + .from(vendorTypes) + .where(where || undefined); + + return Number(result[0]?.count || 0); +} + +/** + * 협력업체 타입 추가 + */ +export async function insertVendorType( + tx: any, + data: { + code: string; + nameKo: string; + nameEn: string; + } +) { + const insertedRows = await tx + .insert(vendorTypes) + .values(data); + + // 삽입된 데이터 가져오기 + return tx.select() + .from(vendorTypes) + .where(eq(vendorTypes.code, data.code)) + .limit(1); +} + +/** + * 단일 협력업체 타입 업데이트 + */ +export async function updateVendorType( + tx: any, + id: number, + data: Partial<{ + code: string; + nameKo: string; + nameEn: string; + }> +) { + await tx + .update(vendorTypes) + .set({ + ...data, + updatedAt: new Date() + }) + .where(eq(vendorTypes.id, id)); + + // 업데이트된 데이터 가져오기 + return tx.select() + .from(vendorTypes) + .where(eq(vendorTypes.id, id)) + .limit(1); +} + +/** + * ID로 단일 협력업체 타입 삭제 + */ +export async function deleteVendorTypeById(tx: any, id: number) { + // 삭제 전 데이터 가져오기 (필요한 경우) + const deletedRecord = await tx.select() + .from(vendorTypes) + .where(eq(vendorTypes.id, id)) + .limit(1); + + // 데이터 삭제 + await tx + .delete(vendorTypes) + .where(eq(vendorTypes.id, id)); + + return deletedRecord; +} + +/** + * 다수의 ID로 여러 협력업체 타입 삭제 + */ +export async function deleteVendorTypesByIds(tx: any, ids: number[]) { + // 삭제 전 데이터 가져오기 (필요한 경우) + const deletedRecords = await tx.select() + .from(vendorTypes) + .where(inArray(vendorTypes.id, ids)); + + // 데이터 삭제 + await tx + .delete(vendorTypes) + .where(inArray(vendorTypes.id, ids)); + + return deletedRecords; +}
\ No newline at end of file diff --git a/lib/vendor-type/service.ts b/lib/vendor-type/service.ts new file mode 100644 index 00000000..8624bb0e --- /dev/null +++ b/lib/vendor-type/service.ts @@ -0,0 +1,239 @@ +"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) + +import { revalidateTag, unstable_noStore } from "next/cache"; +import db from "@/db/db"; +import { customAlphabet } from "nanoid"; + +import { filterColumns } from "@/lib/filter-columns"; +import { unstable_cache } from "@/lib/unstable-cache"; +import { getErrorMessage } from "@/lib/handle-error"; + +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { CreateVendorTypeSchema, GetVendorTypesSchema, UpdateVendorTypeSchema } from "./validations"; +import { + countVendorTypes, + deleteVendorTypeById, + deleteVendorTypesByIds, + findAllVendorTypes, + insertVendorType, + selectVendorTypes, + updateVendorType +} from "./repository"; +import { vendorTypes } from "@/db/schema"; + +/* ----------------------------------------------------- + 1) 조회 관련 +----------------------------------------------------- */ + +/** + * 복잡한 조건으로 VendorType 목록을 조회 (+ pagination) 하고, + * 총 개수에 따라 pageCount를 계산해서 리턴. + * Next.js의 unstable_cache를 사용해 일정 시간 캐시. + */ +export async function getVendorTypes(input: GetVendorTypesSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: vendorTypes, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(vendorTypes.nameKo, s), + ilike(vendorTypes.nameEn, s), + ilike(vendorTypes.code, s) + ); + } + + const finalWhere = and( + advancedWhere, + globalWhere + ); + + // 아니면 ilike, inArray, gte 등으로 where 절 구성 + const where = finalWhere; + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(vendorTypes[item.id]) : asc(vendorTypes[item.id]) + ) + : [asc(vendorTypes.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectVendorTypes(tx, { + where, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countVendorTypes(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + return { data, pageCount }; + } catch (err) { + console.log(err, "err") + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["vendorTypes"], // revalidateTag("vendorTypes") 호출 시 무효화 + } + )(); +} + +/* ----------------------------------------------------- + 2) 생성(Create) +----------------------------------------------------- */ +export interface VendorTypeCreateData { + code?: string; + nameKo: string; + nameEn: string; +} + +/** + * VendorType 생성 + */ +export async function createVendorType(input: VendorTypeCreateData) { + unstable_noStore(); // Next.js 서버 액션 캐싱 방지 + + try { + if (!input.nameKo || !input.nameEn) { + return { + success: false, + message: "한국어 이름과 영어 이름은 필수입니다", + data: null, + error: "필수 필드 누락" + }; + } + + // 코드가 없으면 자동 생성 (예: nameEn의 소문자화 + nanoid) + const code = input.code || `${input.nameEn.toLowerCase().replace(/\s+/g, '-')}-${customAlphabet('1234567890abcdef', 6)()}`; + + // result 변수에 명시적으로 타입과 초기값 할당 + let result: any[] = []; + + // 트랜잭션 결과를 result에 할당 + result = await db.transaction(async (tx) => { + // 기존 코드 확인 (code는 unique) + const existingVendorType = input.code ? await tx.query.vendorTypes.findFirst({ + where: eq(vendorTypes.code, input.code), + }) : null; + + let txResult; + if (existingVendorType) { + // 기존 vendorType 업데이트 + txResult = await updateVendorType(tx, existingVendorType.id, { + nameKo: input.nameKo, + nameEn: input.nameEn, + }); + } else { + // 새 vendorType 생성 + txResult = await insertVendorType(tx, { + code, + nameKo: input.nameKo, + nameEn: input.nameEn, + }); + } + + return txResult; + }); + + // 캐시 무효화 + revalidateTag("vendorTypes"); + + return { + success: true, + data: result[0] || null, + error: null + }; + } catch (err) { + console.error("협력업체 타입 생성/업데이트 오류:", err); + + // 중복 키 오류 처리 + if (err instanceof Error && err.message.includes("unique constraint")) { + return { + success: false, + message: "이미 존재하는 협력업체 타입 코드입니다", + data: null, + error: "중복 키 오류" + }; + } + + return { + success: false, + message: getErrorMessage(err), + data: null, + error: getErrorMessage(err) + }; + } +} + +/* ----------------------------------------------------- + 3) 업데이트 +----------------------------------------------------- */ + +/** 단건 업데이트 */ +export async function modifyVendorType(input: UpdateVendorTypeSchema & { id: number }) { + unstable_noStore(); + try { + const data = await db.transaction(async (tx) => { + const [res] = await updateVendorType(tx, input.id, { + nameKo: input.nameKo, + nameEn: input.nameEn, + }); + return res; + }); + + revalidateTag("vendorTypes"); + return { data, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 단건 삭제 */ +export async function removeVendorType(input: { id: number }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + await deleteVendorTypeById(tx, input.id); + }); + + revalidateTag("vendorTypes"); + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +} + +/** 복수 삭제 */ +export async function removeVendorTypes(input: { ids: number[] }) { + unstable_noStore(); + try { + await db.transaction(async (tx) => { + await deleteVendorTypesByIds(tx, input.ids); + }); + + revalidateTag("vendorTypes"); + return { data: null, error: null }; + } catch (err) { + return { data: null, error: getErrorMessage(err) }; + } +}
\ No newline at end of file diff --git a/lib/vendor-type/table/add-vendorTypes-dialog.tsx b/lib/vendor-type/table/add-vendorTypes-dialog.tsx new file mode 100644 index 00000000..74e1d10c --- /dev/null +++ b/lib/vendor-type/table/add-vendorTypes-dialog.tsx @@ -0,0 +1,158 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" + +import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { useToast } from "@/hooks/use-toast" +// react-hook-form + shadcn/ui Form +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { createVendorType } from "../service" +import { CreateVendorTypeSchema, createVendorTypeSchema } from "../validations" + +export function AddVendorTypeDialog() { + const [open, setOpen] = React.useState(false) + const { toast } = useToast() + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // react-hook-form 세팅 + const form = useForm<CreateVendorTypeSchema>({ + resolver: zodResolver(createVendorTypeSchema), + defaultValues: { + nameKo: "", + nameEn: "", + }, + mode: "onChange", // 입력값이 변경될 때마다 유효성 검사 + }) + + // 폼 값 감시 + const nameKo = form.watch("nameKo") + const nameEn = form.watch("nameEn") + + // 두 필드가 모두 입력되었는지 확인 + const isFormValid = nameKo.trim() !== "" && nameEn.trim() !== "" + + async function onSubmit(data: CreateVendorTypeSchema) { + setIsSubmitting(true) + try { + const result = await createVendorType(data) + if (result.error) { + toast({ + title: "오류 발생", + description: result.error, + variant: "destructive", + }) + return + } + + // 성공 시 모달 닫고 폼 리셋 + toast({ + title: "성공", + description: "협력업체 타입이 성공적으로 생성되었습니다.", + }) + form.reset() + setOpen(false) + } catch (error) { + toast({ + title: "오류 발생", + description: "협력업체 타입 생성 중 오류가 발생했습니다.", + variant: "destructive", + }) + } finally { + setIsSubmitting(false) + } + } + + function handleDialogOpenChange(nextOpen: boolean) { + if (!nextOpen) { + form.reset() + } + setOpen(nextOpen) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogOpenChange}> + {/* 모달을 열기 위한 버튼 */} + <DialogTrigger asChild> + <Button variant="default" size="sm"> + Add Vendor Type + </Button> + </DialogTrigger> + + <DialogContent> + <DialogHeader> + <DialogTitle>새 협력업체 타입 생성</DialogTitle> + <DialogDescription> + 새 Vendor Type 정보를 입력하고 <b>Create</b> 버튼을 누르세요. + </DialogDescription> + </DialogHeader> + + {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */} + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)}> + <div className="space-y-4 py-4"> + <FormField + control={form.control} + name="nameKo" + render={({ field }) => ( + <FormItem> + <FormLabel>업체 유형(한글)<span className="text-red-500"> *</span></FormLabel> + <FormControl> + <Input + placeholder="예: 강재, 블록" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + <FormField + control={form.control} + name="nameEn" + render={({ field }) => ( + <FormItem> + <FormLabel>업체 유형(영문) <span className="text-red-500"> *</span></FormLabel> + <FormControl> + <Input + placeholder="e.g. Steel, Block" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => setOpen(false)} + > + Cancel + </Button> + <Button + type="submit" + disabled={isSubmitting || !isFormValid} + > + {isSubmitting ? "Creating..." : "Create"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-type/table/delete-vendorTypes-dialog.tsx b/lib/vendor-type/table/delete-vendorTypes-dialog.tsx new file mode 100644 index 00000000..fa9376b6 --- /dev/null +++ b/lib/vendor-type/table/delete-vendorTypes-dialog.tsx @@ -0,0 +1,149 @@ +"use client" + +import * as React from "react" +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 { VendorTypes } from "@/db/schema" +import { removeVendorTypes } from "../service" + + +interface DeleteItemsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendorTypes: Row<VendorTypes>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteVendorTypesDialog({ + vendorTypes, + showTrigger = true, + onSuccess, + ...props +}: DeleteItemsDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await removeVendorTypes({ + ids: vendorTypes.map((item) => item.id), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Tasks deleted") + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({vendorTypes.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{vendorTypes.length}</span> + {vendorTypes.length === 1 ? " task" : " tasks"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({vendorTypes.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{vendorTypes.length}</span> + {vendorTypes.length === 1 ? " item" : " items"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/rfqs/cbe-table/feature-flags-provider.tsx b/lib/vendor-type/table/feature-flags-provider.tsx index 81131894..81131894 100644 --- a/lib/rfqs/cbe-table/feature-flags-provider.tsx +++ b/lib/vendor-type/table/feature-flags-provider.tsx diff --git a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx b/lib/vendor-type/table/feature-flags.tsx index 81131894..aaae6af2 100644 --- a/lib/vendor-rfq-response/vendor-tbe-table/feature-flags-provider.tsx +++ b/lib/vendor-type/table/feature-flags.tsx @@ -4,7 +4,6 @@ import * as React from "react" import { useQueryState } from "nuqs" import { dataTableConfig, type DataTableConfig } from "@/config/data-table" -import { cn } from "@/lib/utils" import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group" import { Tooltip, @@ -14,33 +13,27 @@ import { type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"] -interface FeatureFlagsContextProps { +interface TasksTableContextProps { featureFlags: FeatureFlagValue[] setFeatureFlags: (value: FeatureFlagValue[]) => void } -const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({ +const TasksTableContext = React.createContext<TasksTableContextProps>({ featureFlags: [], setFeatureFlags: () => {}, }) -export function useFeatureFlags() { - const context = React.useContext(FeatureFlagsContext) +export function useTasksTable() { + const context = React.useContext(TasksTableContext) if (!context) { - throw new Error( - "useFeatureFlags must be used within a FeatureFlagsProvider" - ) + throw new Error("useTasksTable must be used within a TasksTableProvider") } return context } -interface FeatureFlagsProviderProps { - children: React.ReactNode -} - -export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { +export function TasksTableProvider({ children }: React.PropsWithChildren) { const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>( - "flags", + "featureFlags", { defaultValue: [], parse: (value) => value.split(",") as FeatureFlagValue[], @@ -48,12 +41,11 @@ export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { eq: (a, b) => a.length === b.length && a.every((value, index) => value === b[index]), clearOnDefault: true, - shallow: false, } ) return ( - <FeatureFlagsContext.Provider + <TasksTableContext.Provider value={{ featureFlags, setFeatureFlags: (value) => void setFeatureFlags(value), @@ -66,24 +58,20 @@ export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { size="sm" value={featureFlags} onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)} - className="w-fit gap-0" + className="w-fit" > - {dataTableConfig.featureFlags.map((flag, index) => ( + {dataTableConfig.featureFlags.map((flag) => ( <Tooltip key={flag.value}> <ToggleGroupItem value={flag.value} - className={cn( - "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90", - { - "rounded-l-sm border-r-0": index === 0, - "rounded-r-sm": - index === dataTableConfig.featureFlags.length - 1, - } - )} + className="whitespace-nowrap px-3 text-xs" asChild > <TooltipTrigger> - <flag.icon className="size-3.5 shrink-0" aria-hidden="true" /> + <flag.icon + className="mr-2 size-3.5 shrink-0" + aria-hidden="true" + /> {flag.label} </TooltipTrigger> </ToggleGroupItem> @@ -103,6 +91,6 @@ export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) { </ToggleGroup> </div> {children} - </FeatureFlagsContext.Provider> + </TasksTableContext.Provider> ) } diff --git a/lib/vendor-type/table/import-excel-button.tsx b/lib/vendor-type/table/import-excel-button.tsx new file mode 100644 index 00000000..bba9a117 --- /dev/null +++ b/lib/vendor-type/table/import-excel-button.tsx @@ -0,0 +1,265 @@ +"use client" + +import * as React from "react" +import { Upload } from "lucide-react" +import { toast } from "sonner" +import * as ExcelJS from 'exceljs' + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Progress } from "@/components/ui/progress" +import { processFileImport } from "./import-vendorTypes-handler" // 별도 파일로 분리 + +interface ImportVendorTypeButtonProps { + onSuccess?: () => void +} + +export function ImportVendorTypeButton({ onSuccess }: ImportVendorTypeButtonProps) { + const [open, setOpen] = React.useState(false) + const [file, setFile] = React.useState<File | null>(null) + const [isUploading, setIsUploading] = React.useState(false) + const [progress, setProgress] = React.useState(0) + const [error, setError] = React.useState<string | null>(null) + const fileInputRef = React.useRef<HTMLInputElement>(null) + + // 파일 선택 처리 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (!selectedFile) return + + if (!selectedFile.name.endsWith('.xlsx') && !selectedFile.name.endsWith('.xls')) { + setError("Excel 파일(.xlsx 또는 .xls)만 가능합니다.") + return + } + + setFile(selectedFile) + setError(null) + } + + // 데이터 가져오기 처리 + const handleImport = async () => { + if (!file) { + setError("가져올 파일을 선택해주세요.") + return + } + + try { + setIsUploading(true) + setProgress(0) + setError(null) + + // 파일을 ArrayBuffer로 읽기 + const arrayBuffer = await file.arrayBuffer(); + + // ExcelJS 워크북 로드 + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(arrayBuffer); + + // 첫 번째 워크시트 가져오기 + const worksheet = workbook.worksheets[0]; + if (!worksheet) { + throw new Error("Excel 파일에 워크시트가 없습니다."); + } + + // 헤더 행 찾기 + let headerRowIndex = 1; + let headerRow: ExcelJS.Row | undefined; + let headerValues: (string | null)[] = []; + + worksheet.eachRow((row, rowNumber) => { + const values = row.values as (string | null)[]; + if (!headerRow && values.some(v => v === "업체 유형(한글)" || v === "업체 유형 (한글)" ||v === "업체 유형(영어)" || v === "업체 유형 (영어)"||v === "업체 유형(영문)" || v === "업체 유형 (영문)")) { + headerRowIndex = rowNumber; + headerRow = row; + headerValues = [...values]; + } + }); + + if (!headerRow) { + throw new Error("Excel 파일에서 헤더 행을 찾을 수 없습니다."); + } + + // 헤더를 기반으로 인덱스 매핑 생성 + const headerMapping: Record<string, number> = {}; + headerValues.forEach((value, index) => { + if (typeof value === 'string') { + headerMapping[value] = index; + } + }); + + // 필수 헤더 확인 + const requiredHeaders = ["업체 유형(한글)", "업체 유형(영문)"]; + const alternativeHeaders = { + "업체 유형(한글)": ["업체 유형 (한글)"], + "업체 유형(영문)": ["업체 유형(영어)", "업체 유형 (영어)", "업체 유형(영문)", "업체 유형 (영문)"], + }; + + // 헤더 매핑 확인 (대체 이름 포함) + const missingHeaders = requiredHeaders.filter(header => { + const alternatives = alternativeHeaders[header as keyof typeof alternativeHeaders] || []; + return !(header in headerMapping) && + !alternatives.some(alt => alt in headerMapping); + }); + + if (missingHeaders.length > 0) { + throw new Error(`다음 필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`); + } + + // 데이터 행 추출 (헤더 이후 행부터) + const dataRows: Record<string, any>[] = []; + + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > headerRowIndex) { + const rowData: Record<string, any> = {}; + const values = row.values as (string | null | undefined)[]; + + // 헤더 매핑에 따라 데이터 추출 + Object.entries(headerMapping).forEach(([header, index]) => { + rowData[header] = values[index] || ""; + }); + + // 빈 행이 아닌 경우만 추가 + if (Object.values(rowData).some(value => value && value.toString().trim() !== "")) { + dataRows.push(rowData); + } + } + }); + + if (dataRows.length === 0) { + throw new Error("Excel 파일에 가져올 데이터가 없습니다."); + } + + // 진행 상황 업데이트를 위한 콜백 + const updateProgress = (current: number, total: number) => { + const percentage = Math.round((current / total) * 100); + setProgress(percentage); + }; + + // 실제 데이터 처리는 별도 함수에서 수행 + const result = await processFileImport( + dataRows, + updateProgress + ); + + // 처리 완료 + toast.success(`${result.successCount}개의 아이템이 성공적으로 가져와졌습니다.`); + + if (result.errorCount > 0) { + toast.warning(`${result.errorCount}개의 항목은 처리할 수 없었습니다.`); + } + + // 상태 초기화 및 다이얼로그 닫기 + setFile(null); + setOpen(false); + + // 성공 콜백 호출 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("Excel 파일 처리 중 오류 발생:", error); + setError(error instanceof Error ? error.message : "파일 처리 중 오류가 발생했습니다."); + } finally { + setIsUploading(false); + } + }; + + // 다이얼로그 열기/닫기 핸들러 + const handleOpenChange = (newOpen: boolean) => { + if (!newOpen) { + // 닫을 때 상태 초기화 + setFile(null) + setError(null) + setProgress(0) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + setOpen(newOpen) + } + + return ( + <> + <Button + variant="outline" + size="sm" + className="gap-2" + onClick={() => setOpen(true)} + disabled={isUploading} + > + <Upload className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Import</span> + </Button> + + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>업체유형 가져오기</DialogTitle> + <DialogDescription> + 업체유형을 Excel 파일에서 가져옵니다. + <br /> + 올바른 형식의 Excel 파일(.xlsx)을 업로드하세요. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4 py-4"> + <div className="flex items-center gap-4"> + <input + type="file" + ref={fileInputRef} + className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm file:border-0 file:bg-transparent file:text-foreground file:font-medium" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isUploading} + /> + </div> + + {file && ( + <div className="text-sm text-muted-foreground"> + 선택된 파일: <span className="font-medium">{file.name}</span> ({(file.size / 1024).toFixed(1)} KB) + </div> + )} + + {isUploading && ( + <div className="space-y-2"> + <Progress value={progress} /> + <p className="text-sm text-muted-foreground text-center"> + {progress}% 완료 + </p> + </div> + )} + + {error && ( + <div className="text-sm font-medium text-destructive"> + {error} + </div> + )} + </div> + + <DialogFooter> + <Button + variant="outline" + onClick={() => setOpen(false)} + disabled={isUploading} + > + 취소 + </Button> + <Button + onClick={handleImport} + disabled={!file || isUploading} + > + {isUploading ? "처리 중..." : "가져오기"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-type/table/import-vendorTypes-handler.tsx b/lib/vendor-type/table/import-vendorTypes-handler.tsx new file mode 100644 index 00000000..85e03e5e --- /dev/null +++ b/lib/vendor-type/table/import-vendorTypes-handler.tsx @@ -0,0 +1,114 @@ +"use client" + +import { z } from "zod" +import { createVendorType } from "../service"; + +// 아이템 데이터 검증을 위한 Zod 스키마 +const itemSchema = z.object({ + nameKo: z.string().min(1, "업체 유형(한글)은 필수입니다"), + nameEn: z.string().min(1, "업체 유형(영문)은 필수입니다"), +}); + +interface ProcessResult { + successCount: number; + errorCount: number; + errors?: Array<{ row: number; message: string }>; +} + +/** + * Excel 파일에서 가져온 아이템 데이터 처리하는 함수 + */ +export async function processFileImport( + jsonData: any[], + progressCallback?: (current: number, total: number) => void +): Promise<ProcessResult> { + // 결과 카운터 초기화 + let successCount = 0; + let errorCount = 0; + const errors: Array<{ row: number; message: string }> = []; + + // 빈 행 등 필터링 + const dataRows = jsonData.filter(row => { + // 빈 행 건너뛰기 + if (Object.values(row).every(val => !val)) { + return false; + } + return true; + }); + + // 데이터 행이 없으면 빈 결과 반환 + if (dataRows.length === 0) { + return { successCount: 0, errorCount: 0 }; + } + + // 각 행에 대해 처리 + for (let i = 0; i < dataRows.length; i++) { + const row = dataRows[i]; + const rowIndex = i + 1; // 사용자에게 표시할 행 번호는 1부터 시작 + + // 진행 상황 콜백 호출 + if (progressCallback) { + progressCallback(i + 1, dataRows.length); + } + + try { + // 필드 매핑 (한글/영문 필드명 모두 지원) + const nameKo = row["업체 유형(한글)"] || row["업체 유형 (한글)"] || ""; + const nameEn = row["업체 유형(영문)"] || row["업체 유형 (영문)"] || row["업체 유형(영어)"] || row["업체 유형 (영어)"] || ""; + + // 데이터 정제 + const cleanedRow = { + nameKo: typeof nameKo === 'string' ? nameKo.trim() : String(nameKo).trim(), + nameEn: typeof nameEn === 'string' ? nameEn.trim() : String(nameEn).trim(), + }; + + // 데이터 유효성 검사 + const validationResult = itemSchema.safeParse(cleanedRow); + + if (!validationResult.success) { + const errorMessage = validationResult.error.errors.map( + err => `${err.path.join('.')}: ${err.message}` + ).join(', '); + + errors.push({ row: rowIndex, message: errorMessage }); + errorCount++; + continue; + } + + // 아이템 생성 서버 액션 호출 + const result = await createVendorType({ + nameKo: cleanedRow.nameKo, + nameEn: cleanedRow.nameEn, + }); + + if (result.success || !result.error) { + successCount++; + } else { + errors.push({ + row: rowIndex, + message: result.message || result.error || "알 수 없는 오류" + }); + errorCount++; + } + } catch (error) { + console.error(`${rowIndex}행 처리 오류:`, error); + errors.push({ + row: rowIndex, + message: error instanceof Error ? error.message : "알 수 없는 오류" + }); + errorCount++; + } + + // 비동기 작업 쓰로틀링 + if (i % 5 === 0) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + // 처리 결과 반환 + return { + successCount, + errorCount, + errors: errors.length > 0 ? errors : undefined + }; +}
\ No newline at end of file diff --git a/lib/vendor-type/table/update-vendorTypes-sheet.tsx b/lib/vendor-type/table/update-vendorTypes-sheet.tsx new file mode 100644 index 00000000..d096a706 --- /dev/null +++ b/lib/vendor-type/table/update-vendorTypes-sheet.tsx @@ -0,0 +1,151 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" + +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +import { Input } from "@/components/ui/input" +import { UpdateVendorTypeSchema, updateVendorTypeSchema } from "../validations" +import { modifyVendorType } from "../service" +import { VendorTypes } from "@/db/schema" + +interface UpdateTypeSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + vendorType: VendorTypes | null +} + +export function UpdateTypeSheet({ vendorType, ...props }: UpdateTypeSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateVendorTypeSchema>({ + resolver: zodResolver(updateVendorTypeSchema), + defaultValues: { + nameKo: vendorType?.nameKo ?? "", + nameEn: vendorType?.nameEn ?? "", + + }, + }) + + + React.useEffect(() => { + if (vendorType) { + form.reset({ + nameKo: vendorType.nameKo ?? "", + nameEn: vendorType.nameEn ?? "", + }); + } + }, [vendorType, form]); + + function onSubmit(input: UpdateVendorTypeSchema) { + startUpdateTransition(async () => { + if (!vendorType) return + + const { error } = await modifyVendorType({ + id: vendorType.id, + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("Item updated") + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>Update vendorType</SheetTitle> + <SheetDescription> + Update the vendorType details and save the changes + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + + <FormField + control={form.control} + name="nameKo" + render={({ field }) => ( + <FormItem> + <FormLabel>업체 유형 (한글)</FormLabel> + <FormControl> + <Input + placeholder="e.g." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="nameEn" + render={({ field }) => ( + <FormItem> + <FormLabel>업체 유형 (영문)</FormLabel> + <FormControl> + <Input + placeholder="e.g." + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + Cancel + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Save + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +} diff --git a/lib/vendor-type/table/vendorTypes-excel-template.tsx b/lib/vendor-type/table/vendorTypes-excel-template.tsx new file mode 100644 index 00000000..a48e807e --- /dev/null +++ b/lib/vendor-type/table/vendorTypes-excel-template.tsx @@ -0,0 +1,78 @@ +import * as ExcelJS from 'exceljs'; +import { saveAs } from "file-saver"; + +/** + * 업체 유형 데이터 가져오기를 위한 Excel 템플릿 파일 생성 및 다운로드 + */ +export async function exportVendorTypeTemplate() { + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'Item Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet('업체 유형'); + + // 컬럼 헤더 정의 및 스타일 적용 + worksheet.columns = [ + { header: '업체 유형(한글)', key: 'nameKo', width: 50 }, + { header: '업체 유형(영문)', key: 'nameEn', width: 50 }, + ]; + + // 헤더 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 테두리 스타일 적용 + headerRow.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + + // 샘플 데이터 추가 + const sampleData = [ + { nameKo: 'ITEM001', nameEn: '샘플 업체 유형 1', }, + { nameKo: 'ITEM002', nameEn: '샘플 업체 유형 2', } + ]; + + // 데이터 행 추가 + sampleData.forEach(item => { + worksheet.addRow(item); + }); + + // 데이터 행 스타일 적용 + worksheet.eachRow((row, rowNumber) => { + if (rowNumber > 1) { // 헤더를 제외한 데이터 행 + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + } + }); + + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, 'vendor-type-template.xlsx'); + return true; + } catch (error) { + console.error('Excel 템플릿 생성 오류:', error); + throw error; + } +}
\ No newline at end of file diff --git a/lib/vendor-type/table/vendorTypes-table-columns.tsx b/lib/vendor-type/table/vendorTypes-table-columns.tsx new file mode 100644 index 00000000..b5cfca71 --- /dev/null +++ b/lib/vendor-type/table/vendorTypes-table-columns.tsx @@ -0,0 +1,179 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis } from "lucide-react" +import { toast } from "sonner" + +import { getErrorMessage } from "@/lib/handle-error" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { VendorTypes } from "@/db/schema" +import { VendorTypesColumnsConfig } from "@/config/VendorTypesColumnsConfig" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorTypes> | null>> +} + +/** + * tanstack table 컬럼 정의 (중첩 헤더 버전) + */ +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorTypes>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<VendorTypes> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size:40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<VendorTypes> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // ---------------------------------------------------------------- + // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 + // ---------------------------------------------------------------- + // 3-1) groupMap: { [groupName]: ColumnDef<VendorTypes>[] } + const groupMap: Record<string, ColumnDef<VendorTypes>[]> = {} + + VendorTypesColumnsConfig.forEach((cfg) => { + // 만약 group가 없으면 "_noGroup" 처리 + const groupName = cfg.group || "_noGroup" + + if (!groupMap[groupName]) { + groupMap[groupName] = [] + } + + // child column 정의 + const childCol: ColumnDef<VendorTypes> = { + accessorKey: cfg.id, + enableResizing: true, + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title={cfg.label} /> + ), + meta: { + excelHeader: cfg.excelHeader, + group: cfg.group, + type: cfg.type, + }, + cell: ({ row, cell }) => { + + if (cfg.id === "createdAt"||cfg.id === "updatedAt") { + const dateVal = cell.getValue() as Date + return formatDate(dateVal) + } + + return row.getValue(cfg.id) ?? "" + }, + } + + groupMap[groupName].push(childCol) + }) + + // ---------------------------------------------------------------- + // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 + // ---------------------------------------------------------------- + const nestedColumns: ColumnDef<VendorTypes>[] = [] + + // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 + // 여기서는 그냥 Object.entries 순서 + Object.entries(groupMap).forEach(([groupName, colDefs]) => { + if (groupName === "_noGroup") { + // 그룹 없음 → 그냥 최상위 레벨 컬럼 + nestedColumns.push(...colDefs) + } else { + // 상위 컬럼 + nestedColumns.push({ + id: groupName, + header: groupName, // "Basic Info", "Metadata" 등 + columns: colDefs, + }) + } + }) + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열: select, nestedColumns, actions + // ---------------------------------------------------------------- + return [ + selectColumn, + ...nestedColumns, + actionsColumn, + ] +}
\ No newline at end of file diff --git a/lib/vendor-type/table/vendorTypes-table-toolbar-actions.tsx b/lib/vendor-type/table/vendorTypes-table-toolbar-actions.tsx new file mode 100644 index 00000000..de56c42f --- /dev/null +++ b/lib/vendor-type/table/vendorTypes-table-toolbar-actions.tsx @@ -0,0 +1,162 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileDown } from "lucide-react" +import * as ExcelJS from 'exceljs' +import { saveAs } from "file-saver" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +import { AddVendorTypeDialog } from "./add-vendorTypes-dialog" +import { exportVendorTypeTemplate } from "./vendorTypes-excel-template" +import { VendorTypes } from "@/db/schema" +import { DeleteVendorTypesDialog } from "./delete-vendorTypes-dialog" +import { ImportVendorTypeButton } from "./import-excel-button" + +interface ItemsTableToolbarActionsProps { + table: Table<VendorTypes> +} + +export function ItemsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { + const [refreshKey, setRefreshKey] = React.useState(0) + + // 가져오기 성공 후 테이블 갱신 + const handleImportSuccess = () => { + setRefreshKey(prev => prev + 1) + } + + // Excel 내보내기 함수 + const exportTableToExcel = async ( + table: Table<any>, + options: { + filename: string; + excludeColumns?: string[]; + sheetName?: string; + } + ) => { + const { filename, excludeColumns = [], sheetName = "업체 유형 목록" } = options; + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + workbook.creator = 'vendorType Management System'; + workbook.created = new Date(); + + // 워크시트 생성 + const worksheet = workbook.addWorksheet(sheetName); + + // 테이블 데이터 가져오기 + const data = table.getFilteredRowModel().rows.map(row => row.original); + + // 테이블 헤더 가져오기 + const headers = table.getAllColumns() + .filter(column => !excludeColumns.includes(column.id)) + .map(column => ({ + key: column.id, + header: column.columnDef.header?.toString() || column.id + })); + + // 컬럼 정의 + worksheet.columns = headers.map(header => ({ + header: header.header, + key: header.key, + width: 20 // 기본 너비 + })); + + // 스타일 적용 + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' } + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // 데이터 행 추가 + data.forEach(item => { + const row: Record<string, any> = {}; + headers.forEach(header => { + row[header.key] = item[header.key]; + }); + worksheet.addRow(row); + }); + + // 전체 셀에 테두리 추가 + worksheet.eachRow((row, rowNumber) => { + row.eachCell((cell) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + }; + }); + }); + + try { + // 워크북을 Blob으로 변환 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); + saveAs(blob, `${filename}.xlsx`); + return true; + } catch (error) { + console.error("Excel 내보내기 오류:", error); + return false; + } + } + + return ( + <div className="flex items-center gap-2"> + {/* 선택된 로우가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteVendorTypesDialog + vendorTypes={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/* 새 업체 유형 추가 다이얼로그 */} + <AddVendorTypeDialog /> + + {/* Import 버튼 */} + <ImportVendorTypeButton onSuccess={handleImportSuccess} /> + + {/* Export 드롭다운 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem + onClick={() => + exportTableToExcel(table, { + filename: "items", + excludeColumns: ["select", "actions"], + sheetName: "업체 유형 목록" + }) + } + > + <FileDown className="mr-2 h-4 w-4" /> + <span>현재 데이터 내보내기</span> + </DropdownMenuItem> + <DropdownMenuItem onClick={() => exportVendorTypeTemplate()}> + <FileDown className="mr-2 h-4 w-4" /> + <span>템플릿 다운로드</span> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-type/table/vendorTypes-table.tsx b/lib/vendor-type/table/vendorTypes-table.tsx new file mode 100644 index 00000000..67c9d632 --- /dev/null +++ b/lib/vendor-type/table/vendorTypes-table.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} 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 { useFeatureFlags } from "./feature-flags-provider" + +import { getColumns } from "./vendorTypes-table-columns" +import { ItemsTableToolbarActions } from "./vendorTypes-table-toolbar-actions" +import { UpdateTypeSheet } from "./update-vendorTypes-sheet" +import { getVendorTypes } from "../service" +import { VendorTypes } from "@/db/schema" +import { DeleteVendorTypesDialog } from "./delete-vendorTypes-dialog" + +interface ItemsTableProps { + promises: Promise< + [ + Awaited<ReturnType<typeof getVendorTypes>>, + ] + > +} + +export function VendorTypesTable({ promises }: ItemsTableProps) { + const { featureFlags } = useFeatureFlags() + + const [{ data, pageCount }] = + React.use(promises) + + console.log(data) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<VendorTypes> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + /** + * This component can render either a faceted filter or a search filter based on the `options` prop. + * + * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered. + * + * Each `option` object has the following properties: + * @prop {string} label - The label for the filter option. + * @prop {string} value - The value for the filter option. + * @prop {React.ReactNode} [icon] - An optional icon to display next to the label. + * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. + */ + const filterFields: DataTableFilterField<VendorTypes>[] = [ + ] + + /** + * Advanced filter fields for the data table. + * These fields provide more complex filtering options compared to the regular filterFields. + * + * Key differences from regular filterFields: + * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'. + * 2. Enhanced flexibility: Allows for more precise and varied filtering options. + * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI. + * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values. + */ + const advancedFilterFields: DataTableAdvancedFilterField<VendorTypes>[] = [ + { + id: "nameKo", + label: "업체 유형(한글)", + type: "text", + }, + { + id: "nameEn", + label: "업체 유형(En)", + type: "text", + }, + ] + + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", 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} + > + <ItemsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + + </DataTable> + <UpdateTypeSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + vendorType={rowAction?.row.original ?? null} + /> + <DeleteVendorTypesDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + vendorTypes={rowAction?.row.original ? [rowAction?.row.original] : []} + showTrigger={false} + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + </> + ) +} diff --git a/lib/vendor-type/validations.ts b/lib/vendor-type/validations.ts new file mode 100644 index 00000000..146c404e --- /dev/null +++ b/lib/vendor-type/validations.ts @@ -0,0 +1,46 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { VendorTypes } from "@/db/schema" + +export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( + [] + ), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<VendorTypes>().withDefault([ + { id: "createdAt", desc: true }, + ]), + nameKo: parseAsString.withDefault(""), + nameEn: parseAsString.withDefault(""), + from: parseAsString.withDefault(""), + to: parseAsString.withDefault(""), + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + +}) + +export const createVendorTypeSchema = z.object({ + nameKo: z.string(), + nameEn: z.string(), + +}) + +export const updateVendorTypeSchema = z.object({ + nameKo: z.string().optional(), + nameEn: z.string().optional(), +}) + +export type GetVendorTypesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> +export type CreateVendorTypeSchema = z.infer<typeof createVendorTypeSchema> +export type UpdateVendorTypeSchema = z.infer<typeof updateVendorTypeSchema> diff --git a/lib/vendors/repository.ts b/lib/vendors/repository.ts index ff195932..1f59aac0 100644 --- a/lib/vendors/repository.ts +++ b/lib/vendors/repository.ts @@ -2,7 +2,7 @@ import { and, eq, inArray, count, gt, AnyColumn, SQLWrapper, SQL} from "drizzle-orm"; import { PgTransaction } from "drizzle-orm/pg-core"; -import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import { VendorContact, vendorContacts, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, type Vendor } from "@/db/schema/vendors"; import db from '@/db/db'; import { items } from "@/db/schema/items"; import { rfqs,rfqItems, rfqEvaluations, vendorResponses } from "@/db/schema/rfq"; @@ -47,8 +47,33 @@ export async function countVendors( } + export async function selectVendorsWithTypes ( + tx: PgTransaction<any, any, any>, + { where, orderBy, offset, limit }: SelectVendorsOptions +) { + return tx + .select() + .from(vendorsWithTypesView) + .where(where ?? undefined) + .orderBy(...(orderBy ?? [])) + .offset(offset ?? 0) + .limit(limit ?? 20); +} + /** - * 3) INSERT (단일 벤더 생성) + * 2) COUNT + */ +export async function countVendorsWithTypes( + tx: PgTransaction<any, any, any>, + where?: any + ) { + const res = await tx.select({ count: count() }).from(vendorsWithTypesView).where(where); + return res[0]?.count ?? 0; + } + + +/** + * 3) INSERT (단일 협력업체 생성) * - id/createdAt/updatedAt은 DB default 사용 * - 반환값은 "생성된 레코드" 배열 ([newVendor]) */ @@ -60,7 +85,7 @@ export async function insertVendor( } /** - * 4) UPDATE (단일 벤더) + * 4) UPDATE (단일 협력업체) */ export async function updateVendor( tx: PgTransaction<any, any, any>, @@ -75,7 +100,7 @@ export async function updateVendor( } /** - * 5) UPDATE (복수 벤더) + * 5) UPDATE (복수 협력업체) * - 여러 개의 id를 받아 일괄 업데이트 */ export async function updateVendors( @@ -280,3 +305,4 @@ export async function countRfqHistory( return count; } + diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 8f095c0e..87a8336d 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2,12 +2,13 @@ import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, type Vendor } from "@/db/schema/vendors"; +import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema/vendors"; import logger from '@/lib/logger'; import { filterColumns } from "@/lib/filter-columns"; import { unstable_cache } from "@/lib/unstable-cache"; import { getErrorMessage } from "@/lib/handle-error"; +import { headers } from 'next/headers'; import { selectVendors, @@ -24,7 +25,10 @@ import { countVendorItems, insertVendorItem, countRfqHistory, - selectRfqHistory + selectRfqHistory, + selectVendorsWithTypes, + countVendorsWithTypes, + } from "./repository"; import type { @@ -51,7 +55,8 @@ import { items } from "@/db/schema/items"; import { users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { projects, vendorProjectPQs } from "@/db/schema"; +import { contracts, contractsDetailView, projects, vendorProjectPQs, vendorsLogs } from "@/db/schema"; +import { Hospital } from "lucide-react"; /* ----------------------------------------------------- @@ -68,61 +73,63 @@ export async function getVendors(input: GetVendorsSchema) { async () => { try { const offset = (input.page - 1) * input.perPage; - - // 1) 고급 필터 + + // 1) 고급 필터 - vendors 대신 vendorsWithTypesView 사용 const advancedWhere = filterColumns({ - table: vendors, + table: vendorsWithTypesView, filters: input.filters, joinOperator: input.joinOperator, }); - + // 2) 글로벌 검색 let globalWhere; if (input.search) { const s = `%${input.search}%`; globalWhere = or( - ilike(vendors.vendorName, s), - ilike(vendors.vendorCode, s), - ilike(vendors.email, s), - ilike(vendors.status, s) + ilike(vendorsWithTypesView.vendorName, s), + ilike(vendorsWithTypesView.vendorCode, s), + ilike(vendorsWithTypesView.email, s), + ilike(vendorsWithTypesView.status, s), + // 추가: 업체 유형 검색 + ilike(vendorsWithTypesView.vendorTypeName, s) ); } - + // 최종 where 결합 const finalWhere = and(advancedWhere, globalWhere); - + // 간단 검색 (advancedTable=false) 시 예시 const simpleWhere = and( input.vendorName - ? ilike(vendors.vendorName, `%${input.vendorName}%`) + ? ilike(vendorsWithTypesView.vendorName, `%${input.vendorName}%`) : undefined, - input.status ? ilike(vendors.status, input.status) : undefined, + input.status ? ilike(vendorsWithTypesView.status, input.status) : undefined, input.country - ? ilike(vendors.country, `%${input.country}%`) + ? ilike(vendorsWithTypesView.country, `%${input.country}%`) : undefined ); - + // 실제 사용될 where const where = finalWhere; - + // 정렬 const orderBy = input.sort.length > 0 ? input.sort.map((item) => - item.desc ? desc(vendors[item.id]) : asc(vendors[item.id]) + item.desc ? desc(vendorsWithTypesView[item.id]) : asc(vendorsWithTypesView[item.id]) ) - : [asc(vendors.createdAt)]; - + : [asc(vendorsWithTypesView.createdAt)]; + // 트랜잭션 내에서 데이터 조회 const { data, total } = await db.transaction(async (tx) => { - // 1) vendor 목록 조회 - const vendorsData = await selectVendors(tx, { + // 1) vendor 목록 조회 (view 사용) + const vendorsData = await selectVendorsWithTypes(tx, { where, orderBy, offset, limit: input.perPage, }); - + // 2) 각 vendor의 attachments 조회 const vendorsWithAttachments = await Promise.all( vendorsData.map(async (vendor) => { @@ -134,7 +141,7 @@ export async function getVendors(input: GetVendorsSchema) { }) .from(vendorAttachments) .where(eq(vendorAttachments.vendorId, vendor.id)); - + return { ...vendor, hasAttachments: attachments.length > 0, @@ -142,17 +149,18 @@ export async function getVendors(input: GetVendorsSchema) { }; }) ); - + // 3) 전체 개수 - const total = await countVendors(tx, where); + const total = await countVendorsWithTypes(tx, where); return { data: vendorsWithAttachments, total }; }); - + // 페이지 수 const pageCount = Math.ceil(total / input.perPage); - + return { data, pageCount }; } catch (err) { + console.error("Error fetching vendors:", err); // 에러 발생 시 return { data: [], pageCount: 0 }; } @@ -165,7 +173,6 @@ export async function getVendors(input: GetVendorsSchema) { )(); } - export async function getVendorStatusCounts() { return unstable_cache( async () => { @@ -252,34 +259,58 @@ async function storeVendorFiles( } } + +export async function getVendorTypes() { + unstable_noStore(); // Next.js server action caching prevention + + try { + const types = await db + .select({ + id: vendorTypes.id, + code: vendorTypes.code, + nameKo: vendorTypes.nameKo, + nameEn: vendorTypes.nameEn, + }) + .from(vendorTypes) + .orderBy(vendorTypes.nameKo); + + return { data: types, error: null }; + } catch (error) { + return { data: null, error: getErrorMessage(error) }; + } +} + export type CreateVendorData = { vendorName: string + vendorTypeId: number vendorCode?: string + items?: string website?: string taxId: string address?: string email: string phone?: string - + representativeName?: string representativeBirth?: string representativeEmail?: string representativePhone?: string - + creditAgency?: string creditRating?: string cashFlowRating?: string corporateRegistrationNumber?: string - + country?: string status?: "PENDING_REVIEW" | "IN_REVIEW" | "IN_PQ" | "PQ_FAILED" | "APPROVED" | "ACTIVE" | "INACTIVE" | "BLACKLISTED" | "PQ_SUBMITTED" } +// Updated createVendor function with taxId duplicate check export async function createVendor(params: { vendorData: CreateVendorData // 기존의 일반 첨부파일 files?: File[] - + // 신용평가 / 현금흐름 등급 첨부 creditRatingFiles?: File[] cashFlowRatingFiles?: File[] @@ -292,17 +323,17 @@ export async function createVendor(params: { }[] }) { unstable_noStore() // Next.js 서버 액션 캐싱 방지 - + try { const { vendorData, files = [], creditRatingFiles = [], cashFlowRatingFiles = [], contacts } = params - + // 이메일 중복 검사 - 이미 users 테이블에 존재하는지 확인 const existingUser = await db .select({ id: users.id }) .from(users) .where(eq(users.email, vendorData.email)) .limit(1); - + // 이미 사용자가 존재하면 에러 반환 if (existingUser.length > 0) { return { @@ -310,7 +341,22 @@ export async function createVendor(params: { error: `이미 등록된 이메일입니다. 다른 이메일을 사용해주세요. (Email ${vendorData.email} already exists in the system)` }; } - + + // taxId 중복 검사 추가 + const existingVendor = await db + .select({ id: vendors.id }) + .from(vendors) + .where(eq(vendors.taxId, vendorData.taxId)) + .limit(1); + + // 이미 동일한 taxId를 가진 업체가 존재하면 에러 반환 + if (existingVendor.length > 0) { + return { + data: null, + error: `이미 등록된 사업자등록번호입니다. (Tax ID ${vendorData.taxId} already exists in the system)` + }; + } + await db.transaction(async (tx) => { // 1) Insert the vendor (확장 필드도 함께) const [newVendor] = await insertVendor(tx, { @@ -323,36 +369,38 @@ export async function createVendor(params: { website: vendorData.website || null, status: vendorData.status ?? "PENDING_REVIEW", taxId: vendorData.taxId, - + vendorTypeId: vendorData.vendorTypeId, + items: vendorData.items || null, + // 대표자 정보 representativeName: vendorData.representativeName || null, representativeBirth: vendorData.representativeBirth || null, representativeEmail: vendorData.representativeEmail || null, representativePhone: vendorData.representativePhone || null, corporateRegistrationNumber: vendorData.corporateRegistrationNumber || null, - + // 신용/현금흐름 creditAgency: vendorData.creditAgency || null, creditRating: vendorData.creditRating || null, cashFlowRating: vendorData.cashFlowRating || null, }) - + // 2) If there are attached files, store them // (2-1) 일반 첨부 if (files.length > 0) { await storeVendorFiles(tx, newVendor.id, files, "GENERAL") } - + // (2-2) 신용평가 파일 if (creditRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, creditRatingFiles, "CREDIT_RATING") } - + // (2-3) 현금흐름 파일 if (cashFlowRatingFiles.length > 0) { await storeVendorFiles(tx, newVendor.id, cashFlowRatingFiles, "CASH_FLOW_RATING") } - + for (const contact of contacts) { await tx.insert(vendorContacts).values({ vendorId: newVendor.id, @@ -364,7 +412,7 @@ export async function createVendor(params: { }) } }) - + revalidateTag("vendors") return { data: null, error: null } } catch (error) { @@ -377,12 +425,26 @@ export async function createVendor(params: { /** 단건 업데이트 */ export async function modifyVendor( - input: UpdateVendorSchema & { id: string } + input: UpdateVendorSchema & { id: string; userId: number; comment:string; } // userId 추가 ) { unstable_noStore(); try { const updated = await db.transaction(async (tx) => { - // 특정 ID 벤더를 업데이트 + // 1. 업데이트 전에 기존 벤더 정보를 가져옴 + const existingVendor = await tx.query.vendors.findFirst({ + where: eq(vendors.id, parseInt(input.id)), + columns: { + status: true, // 상태 변경 로깅에 필요한 현재 상태만 가져옴 + }, + }); + + if (!existingVendor) { + throw new Error(`Vendor with ID ${input.id} not found`); + } + + const oldStatus = existingVendor.status; + + // 2. 벤더 정보 업데이트 const [res] = await updateVendor(tx, input.id, { vendorName: input.vendorName, vendorCode: input.vendorCode, @@ -391,8 +453,32 @@ export async function modifyVendor( phone: input.phone, email: input.email, website: input.website, + creditAgency: input.creditAgency, + creditRating: input.creditRating, + cashFlowRating: input.cashFlowRating, status: input.status, }); + + // 3. 상태가 변경되었다면 로그 기록 + if (oldStatus !== input.status) { + await tx.insert(vendorsLogs).values({ + vendorId: parseInt(input.id), + userId: input.userId, + action: "status_change", + oldStatus, + newStatus: input.status, + comment: input.comment || `Status changed from ${oldStatus} to ${input.status}`, + }); + } else if (input.comment) { + // 상태 변경이 없더라도 코멘트가 있으면 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: parseInt(input.id), + userId: input.userId, + action: "vendor_updated", + comment: input.comment, + }); + } + return res; }); @@ -414,7 +500,7 @@ export async function modifyVendors(input: { unstable_noStore(); try { const data = await db.transaction(async (tx) => { - // 여러 벤더 일괄 업데이트 + // 여러 협력업체 일괄 업데이트 const [updated] = await updateVendors(tx, input.ids, { // 예: 상태만 일괄 변경 status: input.status, @@ -560,7 +646,7 @@ export async function createVendorContact(input: CreateVendorContactSchema) { return newContact; }); - // 캐시 무효화 (벤더 연락처 목록 등) + // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-contacts-${input.vendorId}`); return { data: null, error: null }; @@ -723,7 +809,7 @@ export async function createVendorItem(input: CreateVendorItemSchema) { return newContact; }); - // 캐시 무효화 (벤더 연락처 목록 등) + // 캐시 무효화 (협력업체 연락처 목록 등) revalidateTag(`vendor-items-${input.vendorId}`); return { data: null, error: null }; @@ -885,98 +971,55 @@ interface CreateCompanyInput { /** - * 벤더 첨부파일 다운로드를 위한 서버 액션 - * @param vendorId 벤더 ID + * 협력업체 첨부파일 다운로드를 위한 서버 액션 + * @param vendorId 협력업체 ID * @param fileId 특정 파일 ID (단일 파일 다운로드시) * @returns 다운로드할 수 있는 임시 URL */ -export async function downloadVendorAttachments(vendorId: number, fileId?: number) { +export async function downloadVendorAttachments(vendorId:number, fileId?:number) { try { - // 벤더 정보 조회 - const vendor = await db.query.vendors.findFirst({ - where: eq(vendors.id, vendorId) + // API 경로 생성 (단일 파일 또는 모든 파일) + const url = fileId + ? `/api/vendors/attachments/download?id=${fileId}&vendorId=${vendorId}` + : `/api/vendors/attachments/download-all?vendorId=${vendorId}`; + + // fetch 요청 (기본적으로 Blob으로 응답 받기) + const response = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, }); - - if (!vendor) { - throw new Error(`벤더 정보를 찾을 수 없습니다. (ID: ${vendorId})`); - } - - // 첨부파일 조회 (특정 파일 또는 모든 파일) - const attachments = fileId - ? await db.select() - .from(vendorAttachments) - .where(eq(vendorAttachments.id, fileId)) - : await db.select() - .from(vendorAttachments) - .where(eq(vendorAttachments.vendorId, vendorId)); - - if (!attachments.length) { - throw new Error('다운로드할 첨부파일이 없습니다.'); + + if (!response.ok) { + throw new Error(`Server responded with ${response.status}: ${response.statusText}`); } - - // 업로드 기본 경로 - const basePath = process.env.UPLOAD_DIR || path.join(process.cwd(), 'uploads'); - - // 단일 파일인 경우 직접 URL 반환 - if (attachments.length === 1) { - const attachment = attachments[0]; - const filePath = `/api/vendors/attachments/download?id=${attachment.id}`; - return { url: filePath, fileName: attachment.fileName }; + + // 파일명 가져오기 (Content-Disposition 헤더에서) + const contentDisposition = response.headers.get('content-disposition'); + let fileName = fileId ? `file-${fileId}.zip` : `vendor-${vendorId}-files.zip`; + + if (contentDisposition) { + const matches = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/.exec(contentDisposition); + if (matches && matches[1]) { + fileName = matches[1].replace(/['"]/g, ''); + } } - - // 다중 파일: 임시 ZIP 생성 후 URL 반환 - // 임시 디렉토리 생성 - const tempDir = path.join(process.cwd(), 'tmp'); - await fsPromises.mkdir(tempDir, { recursive: true }); - - // 고유 ID로 임시 ZIP 파일명 생성 - const tempId = randomUUID(); - const zipFileName = `${vendor.vendorName || `vendor-${vendorId}`}-attachments-${tempId}.zip`; - const zipFilePath = path.join(tempDir, zipFileName); - - // JSZip을 사용하여 ZIP 파일 생성 - const zip = new JSZip(); - - // 파일 읽기 및 추가 작업을 병렬로 처리 - await Promise.all( - attachments.map(async (attachment) => { - const filePath = path.join(basePath, attachment.filePath); - - try { - // 파일 존재 확인 (fsPromises.access 사용) - try { - await fsPromises.access(filePath, fs.constants.F_OK); - } catch (e) { - console.warn(`파일이 존재하지 않습니다: ${filePath}`); - return; // 파일이 없으면 건너뜀 - } - - // 파일 읽기 (fsPromises.readFile 사용) - const fileData = await fsPromises.readFile(filePath); - - // ZIP에 파일 추가 - zip.file(attachment.fileName, fileData); - } catch (error) { - console.warn(`파일을 처리할 수 없습니다: ${filePath}`, error); - // 오류가 있더라도 계속 진행 - } - }) - ); - - // ZIP 생성 및 저장 - const zipContent = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE', compressionOptions: { level: 9 } }); - await fsPromises.writeFile(zipFilePath, zipContent); - - // 임시 ZIP 파일에 접근할 수 있는 URL 생성 - const downloadUrl = `/api/vendors/attachments/download-temp?file=${encodeURIComponent(zipFileName)}`; - - return { - url: downloadUrl, - fileName: `${vendor.vendorName || `vendor-${vendorId}`}-attachments.zip` + + // Blob으로 응답 변환 + const blob = await response.blob(); + + // Blob URL 생성 + const blobUrl = window.URL.createObjectURL(blob); + + return { + url: blobUrl, + fileName, + blob }; } catch (error) { - console.error('첨부파일 다운로드 서버 액션 오류:', error); - throw new Error('첨부파일 다운로드 준비 중 오류가 발생했습니다.'); + console.error('Download API error:', error); + throw error; } } @@ -1016,13 +1059,22 @@ interface ApproveVendorsInput { /** * 선택된 벤더의 상태를 IN_REVIEW로 변경하고 이메일 알림을 발송하는 서버 액션 */ -export async function approveVendors(input: ApproveVendorsInput) { +export async function approveVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { - // 트랜잭션 내에서 벤더 상태 업데이트, 유저 생성 및 이메일 발송 + // 트랜잭션 내에서 협력업체 상태 업데이트, 유저 생성 및 이메일 발송 const result = await db.transaction(async (tx) => { - // 1. 벤더 상태 업데이트 + // 0. 업데이트 전 협력업체 상태 조회 + const vendorsBeforeUpdate = await tx + .select({ + id: vendors.id, + status: vendors.status, + }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ @@ -1032,7 +1084,7 @@ export async function approveVendors(input: ApproveVendorsInput) { .where(inArray(vendors.id, input.ids)) .returning(); - // 2. 업데이트된 벤더 정보 조회 + // 2. 업데이트된 협력업체 정보 조회 const updatedVendors = await tx .select({ id: vendors.id, @@ -1067,18 +1119,35 @@ export async function approveVendors(input: ApproveVendorsInput) { }) ); - // 4. 각 벤더에게 이메일 발송 + // 4. 로그 기록 + await Promise.all( + vendorsBeforeUpdate.map(async (vendorBefore) => { + await tx.insert(vendorsLogs).values({ + vendorId: vendorBefore.id, + userId: input.userId, + action: "status_change", + oldStatus: vendorBefore.status, + newStatus: "IN_REVIEW", + comment: "Vendor approved for review", + }); + }) + ); + + // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Admin Account Created"; - const loginUrl = "http://3.36.56.124:3000/en/login"; + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const baseUrl = `http://${host}` + const loginUrl = `${baseUrl}/en/login`; await sendEmail({ to: vendor.email, @@ -1112,7 +1181,7 @@ export async function approveVendors(input: ApproveVendorsInput) { } } -export async function requestPQVendors(input: ApproveVendorsInput) { +export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); try { @@ -1134,9 +1203,18 @@ export async function requestPQVendors(input: ApproveVendorsInput) { } } - // 트랜잭션 내에서 벤더 상태 업데이트 및 이메일 발송 + // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송 const result = await db.transaction(async (tx) => { - // 1. 벤더 상태 업데이트 + // 0. 업데이트 전 협력업체 상태 조회 + const vendorsBeforeUpdate = await tx + .select({ + id: vendors.id, + status: vendors.status, + }) + .from(vendors) + .where(inArray(vendors.id, input.ids)); + + // 1. 협력업체 상태 업데이트 const [updated] = await tx .update(vendors) .set({ @@ -1146,7 +1224,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) { .where(inArray(vendors.id, input.ids)) .returning(); - // 2. 업데이트된 벤더 정보 조회 + // 2. 업데이트된 협력업체 정보 조회 const updatedVendors = await tx .select({ id: vendors.id, @@ -1169,14 +1247,33 @@ export async function requestPQVendors(input: ApproveVendorsInput) { await tx.insert(vendorProjectPQs).values(vendorProjectPQsData); } - - // 4. 각 벤더에게 이메일 발송 + + // 4. 로그 기록 + await Promise.all( + vendorsBeforeUpdate.map(async (vendorBefore) => { + await tx.insert(vendorsLogs).values({ + vendorId: vendorBefore.id, + userId: input.userId, + action: "status_change", + oldStatus: vendorBefore.status, + newStatus: "IN_PQ", + comment: input.projectId + ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` + : "PQ requested", + }); + }) + ); + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { if (!vendor.email) return; // 이메일이 없으면 스킵 try { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 const subject = input.projectId @@ -1184,7 +1281,7 @@ export async function requestPQVendors(input: ApproveVendorsInput) { : "[eVCP] You are invited to submit PQ"; // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) - const baseLoginUrl = "http://3.36.56.124:3000/en/login"; + const baseLoginUrl = `${host}/partners/pq`; const loginUrl = input.projectId ? `${baseLoginUrl}?projectId=${input.projectId}` : baseLoginUrl; @@ -1192,7 +1289,8 @@ export async function requestPQVendors(input: ApproveVendorsInput) { await sendEmail({ to: vendor.email, subject, - template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 + template:input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 + // template: "vendor-pq-status", // 프로젝트별 템플릿 사용 context: { vendorName: vendor.vendorName, loginUrl, @@ -1225,21 +1323,20 @@ export async function requestPQVendors(input: ApproveVendorsInput) { return { data: null, error: getErrorMessage(err) }; } } - interface SendVendorsInput { ids: number[]; } /** - * APPROVED 상태인 벤더 정보를 기간계 시스템에 전송하고 벤더 코드를 업데이트하는 서버 액션 + * APPROVED 상태인 협력업체 정보를 기간계 시스템에 전송하고 협력업체 코드를 업데이트하는 서버 액션 */ -export async function sendVendors(input: SendVendorsInput) { +export async function sendVendors(input: SendVendorsInput & { userId: number }) { unstable_noStore(); try { // 트랜잭션 내에서 진행 const result = await db.transaction(async (tx) => { - // 1. 선택된 벤더 중 APPROVED 상태인 벤더만 필터링 + // 1. 선택된 협력업체 중 APPROVED 상태인 벤더만 필터링 const approvedVendors = await db.query.vendors.findMany({ where: and( inArray(vendors.id, input.ids), @@ -1255,16 +1352,16 @@ export async function sendVendors(input: SendVendorsInput) { // 2. 각 벤더에 대해 처리 for (const vendor of approvedVendors) { - // 2-1. 벤더 연락처 정보 조회 + // 2-1. 협력업체 연락처 정보 조회 const contacts = await db.query.vendorContacts.findMany({ where: eq(vendorContacts.vendorId, vendor.id) }); - // 2-2. 벤더 가능 아이템 조회 + // 2-2. 협력업체 가능 아이템 조회 const possibleItems = await db.query.vendorPossibleItems.findMany({ where: eq(vendorPossibleItems.vendorId, vendor.id) }); - // 2-3. 벤더 첨부파일 조회 + // 2-3. 협력업체 첨부파일 조회 const attachments = await db.query.vendorAttachments.findMany({ where: eq(vendorAttachments.vendorId, vendor.id), columns: { @@ -1274,8 +1371,7 @@ export async function sendVendors(input: SendVendorsInput) { } }); - - // 2-4. 벤더 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) + // 2-4. 협력업체 정보를 기간계 시스템에 전송 (NextJS API 라우트 사용) const vendorData = { id: vendor.id, vendorName: vendor.vendorName, @@ -1311,7 +1407,7 @@ export async function sendVendors(input: SendVendorsInput) { throw new Error(`Invalid response from ERP system for vendor ${vendor.id}`); } - // 2-5. 벤더 코드 및 상태 업데이트 + // 2-5. 협력업체 코드 및 상태 업데이트 const vendorCode = responseData.vendorCode; const [updated] = await tx @@ -1324,16 +1420,27 @@ export async function sendVendors(input: SendVendorsInput) { .where(eq(vendors.id, vendor.id)) .returning(); - // 2-6. 벤더에게 알림 이메일 발송 + // 2-6. 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: input.userId, + action: "status_change", + oldStatus: "APPROVED", + newStatus: "ACTIVE", + comment: `Sent to ERP system. Vendor code assigned: ${vendorCode}`, + }); + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 2-7. 벤더에게 알림 이메일 발송 if (vendor.email) { - const userLang = "en"; // 기본값, 필요시 벤더 언어 설정에서 가져오기 + const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 const subject = "[eVCP] Vendor Registration Completed"; - const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'http://3.36.56.124:3000' - - const portalUrl = `${baseUrl}/en/partners`; + const portalUrl = `http://${host}/en/partners`; await sendEmail({ to: vendor.email, @@ -1355,12 +1462,20 @@ export async function sendVendors(input: SendVendorsInput) { message: "Successfully sent to ERP system", }); } catch (vendorError) { - // 개별 벤더 처리 오류 기록 + // 개별 협력업체 처리 오류 기록 results.push({ id: vendor.id, success: false, error: getErrorMessage(vendorError), }); + + // 에러가 발생해도 로그는 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: input.userId, + action: "erp_send_failed", + comment: `Failed to send to ERP: ${getErrorMessage(vendorError)}`, + }); } } @@ -1387,55 +1502,68 @@ export async function sendVendors(input: SendVendorsInput) { } } - interface RequestInfoProps { ids: number[]; + userId: number; // 추가: 어떤 사용자가 요청했는지 로깅하기 위함 } -export async function requestInfo({ ids }: RequestInfoProps) { +export async function requestInfo({ ids, userId }: RequestInfoProps) { try { - // 1. 벤더 정보 가져오기 - const vendorList = await db.query.vendors.findMany({ - where: inArray(vendors.id, ids), - }); - - if (!vendorList.length) { - return { error: "벤더 정보를 찾을 수 없습니다." }; - } + return await db.transaction(async (tx) => { + // 1. 협력업체 정보 가져오기 + const vendorList = await tx.query.vendors.findMany({ + where: inArray(vendors.id, ids), + }); - // 2. 각 벤더에게 이메일 보내기 - for (const vendor of vendorList) { - // 이메일이 없는 경우 스킵 - if (!vendor.email) continue; + if (!vendorList.length) { + return { error: "협력업체 정보를 찾을 수 없습니다." }; + } - // 벤더 정보 페이지 URL 생성 - const vendorInfoUrl = `${process.env.NEXT_PUBLIC_APP_URL}/partners/info?vendorId=${vendor.id}`; + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + // 2. 각 벤더에 대한 로그 기록 및 이메일 발송 + for (const vendor of vendorList) { + // 로그 기록 + await tx.insert(vendorsLogs).values({ + vendorId: vendor.id, + userId: userId, + action: "info_requested", + comment: "추가 정보 요청됨", + }); - // 벤더에게 이메일 보내기 - await sendEmail({ - to: vendor.email, - subject: "[EVCP] 추가 정보 요청 / Additional Information Request", - template: "vendor-additional-info", - context: { - vendorName: vendor.vendorName, - vendorInfoUrl: vendorInfoUrl, - language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 - }, - }); - } + // 이메일이 없는 경우 스킵 + if (!vendor.email) continue; + + // 협력업체 정보 페이지 URL 생성 + const vendorInfoUrl = `http://${host}/partners/info?vendorId=${vendor.id}`; + + // 벤더에게 이메일 보내기 + await sendEmail({ + to: vendor.email, + subject: "[EVCP] 추가 정보 요청 / Additional Information Request", + template: "vendor-additional-info", + context: { + vendorName: vendor.vendorName, + vendorInfoUrl: vendorInfoUrl, + language: "ko", // 기본 언어 설정, 벤더의 선호 언어가 있다면 그것을 사용할 수 있음 + }, + }); + } - // 3. 성공적으로 처리됨 - return { success: true }; + // 3. 성공적으로 처리됨 + return { success: true }; + }); } catch (error) { - console.error("벤더 정보 요청 중 오류 발생:", error); - return { error: "벤더 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; + console.error("협력업체 정보 요청 중 오류 발생:", error); + return { error: "협력업체 정보 요청 중 오류가 발생했습니다. 다시 시도해 주세요." }; } } export async function getVendorDetailById(id: number) { try { - // View를 통해 벤더 정보 조회 + // View를 통해 협력업체 정보 조회 const vendor = await db .select() .from(vendorDetailView) @@ -1496,7 +1624,7 @@ export type ContactInfo = { } /** - * 벤더 정보를 업데이트하는 함수 + * 협력업체 정보를 업데이트하는 함수 */ export async function updateVendorInfo(params: { vendorData: UpdateVendorInfoData @@ -1533,7 +1661,7 @@ export async function updateVendorInfo(params: { // 트랜잭션으로 업데이트 수행 await db.transaction(async (tx) => { - // 1. 벤더 정보 업데이트 + // 1. 협력업체 정보 업데이트 await tx.update(vendors).set({ vendorName: vendorData.vendorName, address: vendorData.address || null, @@ -1672,7 +1800,7 @@ export async function updateVendorInfo(params: { return { data: { success: true, - message: '벤더 정보가 성공적으로 업데이트되었습니다.', + message: '협력업체 정보가 성공적으로 업데이트되었습니다.', vendorId: vendorData.id }, error: null @@ -1681,4 +1809,205 @@ export async function updateVendorInfo(params: { console.error("Vendor info update error:", error); return { data: null, error: getErrorMessage(error) } } +} + + + +export interface VendorsLogWithUser { + id: number + vendorCandidateId: number + userId: number + userName: string | null + userEmail: string | null + action: string + oldStatus: string | null + newStatus: string | null + comment: string | null + createdAt: Date +} + +export async function getVendorLogs(vendorId: number): Promise<VendorsLogWithUser[]> { + try { + const logs = await db + .select({ + id: vendorsLogs.id, + vendorCandidateId: vendorsLogs.vendorId, + userId: vendorsLogs.userId, + action: vendorsLogs.action, + oldStatus: vendorsLogs.oldStatus, + newStatus: vendorsLogs.newStatus, + comment: vendorsLogs.comment, + createdAt: vendorsLogs.createdAt, + + // 조인한 users 테이블 필드 + userName: users.name, + userEmail: users.email, + }) + .from(vendorsLogs) + .leftJoin(users, eq(vendorsLogs.userId, users.id)) + .where(eq(vendorsLogs.vendorId, vendorId)) + .orderBy(desc(vendorsLogs.createdAt)) + + return logs + } catch (error) { + console.error("Failed to fetch candidate logs with user info:", error) + throw error + } +} + + + +/** + * 엑셀 내보내기용 벤더 연락처 목록 조회 + * - 페이지네이션 없이 모든 연락처 반환 + */ +export async function exportVendorContacts(vendorId: number) { + try { + const contacts = await db + .select() + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + .orderBy(vendorContacts.isPrimary, vendorContacts.contactName); + + return contacts; + } catch (error) { + console.error("Failed to export vendor contacts:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 아이템 목록 조회 + * - 페이지네이션 없이 모든 아이템 정보 반환 + */ +export async function exportVendorItems(vendorId: number) { + try { + const vendorItems = await db + .select({ + id: vendorItemsView.vendorItemId, + vendorId: vendorItemsView.vendorId, + itemName: vendorItemsView.itemName, + itemCode: vendorItemsView.itemCode, + description: vendorItemsView.description, + createdAt: vendorItemsView.createdAt, + updatedAt: vendorItemsView.updatedAt, + }) + .from(vendorItemsView) + .where(eq(vendorItemsView.vendorId, vendorId)) + .orderBy(vendorItemsView.itemName); + + return vendorItems; + } catch (error) { + console.error("Failed to export vendor items:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 RFQ 목록 조회 + * - 페이지네이션 없이 모든 RFQ 정보 반환 + */ +export async function exportVendorRFQs(vendorId: number) { + try { + const rfqs = await db + .select() + .from(vendorRfqView) + .where(eq(vendorRfqView.vendorId, vendorId)) + .orderBy(vendorRfqView.rfqVendorUpdated); + + return rfqs; + } catch (error) { + console.error("Failed to export vendor RFQs:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 계약 목록 조회 + * - 페이지네이션 없이 모든 계약 정보 반환 + */ +export async function exportVendorContracts(vendorId: number) { + try { + const contracts = await db + .select() + .from(contractsDetailView) + .where(eq(contractsDetailView.vendorId, vendorId)) + .orderBy(contractsDetailView.createdAt); + + return contracts; + } catch (error) { + console.error("Failed to export vendor contracts:", error); + return []; + } +} + +/** + * 엑셀 내보내기용 벤더 정보 조회 + * - 페이지네이션 없이 모든 벤더 정보 반환 + */ +export async function exportVendorDetails(vendorIds: number[]) { + try { + if (!vendorIds.length) return []; + + // 벤더 기본 정보 조회 + const vendorsData = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + taxId: vendors.taxId, + address: vendors.address, + country: vendors.country, + phone: vendors.phone, + email: vendors.email, + website: vendors.website, + status: vendors.status, + representativeName: vendors.representativeName, + representativeBirth: vendors.representativeBirth, + representativeEmail: vendors.representativeEmail, + representativePhone: vendors.representativePhone, + corporateRegistrationNumber: vendors.corporateRegistrationNumber, + creditAgency: vendors.creditAgency, + creditRating: vendors.creditRating, + cashFlowRating: vendors.cashFlowRating, + createdAt: vendors.createdAt, + updatedAt: vendors.updatedAt, + }) + .from(vendors) + .where( + vendorIds.length === 1 + ? eq(vendors.id, vendorIds[0]) + : inArray(vendors.id, vendorIds) + ); + + // 벤더별 상세 정보를 포함하여 반환 + const vendorsWithDetails = await Promise.all( + vendorsData.map(async (vendor) => { + // 연락처 조회 + const contacts = await exportVendorContacts(vendor.id); + + // 아이템 조회 + const items = await exportVendorItems(vendor.id); + + // RFQ 조회 + const rfqs = await exportVendorRFQs(vendor.id); + + // 계약 조회 + const contracts = await exportVendorContracts(vendor.id); + + return { + ...vendor, + vendorContacts: contacts, + vendorItems: items, + vendorRfqs: rfqs, + vendorContracts: contracts, + }; + }) + ); + + return vendorsWithDetails; + } catch (error) { + console.error("Failed to export vendor details:", error); + return []; + } }
\ No newline at end of file diff --git a/lib/vendors/table/approve-vendor-dialog.tsx b/lib/vendors/table/approve-vendor-dialog.tsx index 253c2830..9c175dc5 100644 --- a/lib/vendors/table/approve-vendor-dialog.tsx +++ b/lib/vendors/table/approve-vendor-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { approveVendors } from "../service" +import { useSession } from "next-auth/react" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,19 @@ export function ApproveVendorsDialog({ }: ApprovalVendorDialogProps) { const [isApprovePending, startApproveTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startApproveTransition(async () => { const { error } = await approveVendors({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { @@ -70,7 +79,7 @@ export function ApproveVendorsDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <Check className="size-4" aria-hidden="true" /> - Approve ({vendors.length}) + 가입 Approve ({vendors.length}) </Button> </DialogTrigger> ) : null} diff --git a/lib/vendors/table/request-additional-Info-dialog.tsx b/lib/vendors/table/request-additional-Info-dialog.tsx index 872162dd..2e39a527 100644 --- a/lib/vendors/table/request-additional-Info-dialog.tsx +++ b/lib/vendors/table/request-additional-Info-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { requestInfo } from "../service" +import { useSession } from "next-auth/react" interface RequestInfoDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,18 @@ export function RequestInfoDialog({ }: RequestInfoDialogProps) { const [isRequestPending, startRequestTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } startRequestTransition(async () => { const { error, success } = await requestInfo({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { @@ -58,7 +66,7 @@ export function RequestInfoDialog({ } props.onOpenChange?.(false) - toast.success("추가 정보 요청이 성공적으로 벤더에게 발송되었습니다.") + toast.success("추가 정보 요청이 성공적으로 협력업체에게 발송되었습니다.") onSuccess?.() }) } @@ -76,12 +84,12 @@ export function RequestInfoDialog({ ) : null} <DialogContent> <DialogHeader> - <DialogTitle>벤더 추가 정보 요청 확인</DialogTitle> + <DialogTitle>협력업체 추가 정보 요청 확인</DialogTitle> <DialogDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까? <br /><br /> - 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 추가 정보를 입력하게 됩니다. </DialogDescription> </DialogHeader> @@ -121,12 +129,12 @@ export function RequestInfoDialog({ ) : null} <DrawerContent> <DrawerHeader> - <DrawerTitle>벤더 추가 정보 요청 확인</DrawerTitle> + <DrawerTitle>협력업체 추가 정보 요청 확인</DrawerTitle> <DrawerDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 추가 정보를 요청하시겠습니까? + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 추가 정보를 요청하시겠습니까? <br /><br /> - 요청시 벤더에게 이메일이 발송되며, 벤더는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 신용 평가 및 현금 흐름 정보와 같은 추가 정보를 입력하게 됩니다. </DrawerDescription> </DrawerHeader> diff --git a/lib/vendors/table/request-basicContract-dialog.tsx b/lib/vendors/table/request-basicContract-dialog.tsx new file mode 100644 index 00000000..8d05fbbe --- /dev/null +++ b/lib/vendors/table/request-basicContract-dialog.tsx @@ -0,0 +1,548 @@ +"use client" + +import * as React from "react" +import { type Row } from "@tanstack/react-table" +import { Loader, Send, AlertCircle, Clock, RefreshCw } 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 { Checkbox } from "@/components/ui/checkbox" +import { Badge } from "@/components/ui/badge" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" +import { Vendor } from "@/db/schema/vendors" +import { useSession } from "next-auth/react" +import { getAllTemplates } from "@/lib/basic-contract/service" +import { useState, useEffect } from "react" +import { requestBasicContractInfo } from "@/lib/basic-contract/service" +import { checkContractRequestStatus } from "@/lib/basic-contract/service" +import { BasicContractTemplate } from "@/db/schema" + +interface RequestInfoDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + vendors: Row<Vendor>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +// 계약 요청 상태 인터페이스 +interface VendorTemplateStatus { + vendorId: number; + vendorName: string; + templateId: number; + templateName: string; + status: string; + createdAt: Date; + completedAt?: Date; // 계약 체결 날짜 추가 + isExpired: boolean; // 요청 만료 (30일) + isUpdated: boolean; // 템플릿 업데이트 여부 + isContractExpired: boolean; // 계약 유효기간 만료 여부 (1년) 추가 +} +export function RequestContractDialog({ + vendors, + showTrigger = true, + onSuccess, + ...props +}: RequestInfoDialogProps) { + const [isRequestPending, startRequestTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() + const [templates, setTemplates] = useState<BasicContractTemplate[]>([]) + const [selectedTemplateIds, setSelectedTemplateIds] = useState<number[]>([]) + const [isLoading, setIsLoading] = useState(false) + const [statusLoading, setStatusLoading] = useState(false) + const [statusData, setStatusData] = useState<VendorTemplateStatus[]>([]) + const [forceResend, setForceResend] = useState<Set<string>>(new Set()) + + // 템플릿 및 상태 로드 + useEffect(() => { + loadTemplatesAndStatus(); + }, [vendors]); + + // 템플릿과 현재 요청 상태를 로드하는 함수 + const loadTemplatesAndStatus = async () => { + console.log("loadTemplatesAndStatus") + setIsLoading(true); + setStatusLoading(true); + + try { + // 1. 템플릿 로드 + const allTemplates = await getAllTemplates(); + const activeTemplates = allTemplates.filter(t => t.status === 'ACTIVE'); + setTemplates(activeTemplates); + + // 기본 템플릿 선택 설정 + const allActiveTemplateIds = activeTemplates.map(t => t.id); + setSelectedTemplateIds(allActiveTemplateIds); + + // 2. 현재 계약 요청 상태 확인 + if (vendors.length > 0 && allActiveTemplateIds.length > 0) { + const vendorIds = vendors.map(v => v.id); + const { data } = await checkContractRequestStatus(vendorIds, allActiveTemplateIds); + setStatusData(data || []); + } + } catch (error) { + console.error("데이터 로딩 오류:", error); + toast.error("템플릿 또는 상태 정보를 불러오는데 실패했습니다."); + } finally { + setIsLoading(false); + setStatusLoading(false); + } + }; + + // 체크박스 상태 변경 핸들러 + const handleTemplateToggle = (templateId: number, checked: boolean) => { + if (checked) { + setSelectedTemplateIds(prev => [...prev, templateId]); + } else { + setSelectedTemplateIds(prev => prev.filter(id => id !== templateId)); + } + }; + + // 강제 재전송 토글 + const toggleForceResend = (vendorId: number, templateId: number) => { + const key = `${vendorId}-${templateId}`; + const newForceResend = new Set(forceResend); + + if (newForceResend.has(key)) { + newForceResend.delete(key); + } else { + newForceResend.add(key); + } + + setForceResend(newForceResend); + }; + + const renderStatusBadge = (vendorId: number, templateId: number) => { + const status = statusData.find( + s => s.vendorId === vendorId && s.templateId === templateId + ); + + if (!status || status.status === "NONE") return null; + + // 상태에 따른 배지 스타일 설정 + let badgeVariant = "outline"; + let badgeLabel = ""; + let icon = null; + let tooltip = ""; + + switch (status.status) { + case "PENDING": + badgeVariant = "secondary"; + badgeLabel = "대기중"; + + if (status.isExpired) { + icon = <Clock className="h-3 w-3 mr-1" />; + tooltip = "요청이 만료되었습니다. 재전송이 필요합니다."; + } else if (status.isUpdated) { + icon = <RefreshCw className="h-3 w-3 mr-1" />; + tooltip = "템플릿이 업데이트되었습니다. 재전송이 필요합니다."; + } else { + tooltip = "서명 요청이 진행 중입니다."; + } + break; + + case "COMPLETED": + // 계약 유효기간 만료 확인 + if (status.isContractExpired) { + badgeVariant = "warning"; // 경고 스타일 적용 + badgeLabel = "재계약 필요"; + icon = <Clock className="h-3 w-3 mr-1" />; + tooltip = "계약 유효기간이 만료되었습니다. 재계약이 필요합니다."; + } else { + badgeVariant = "success"; + badgeLabel = "완료됨"; + tooltip = "이미 서명이 완료되었습니다."; + } + break; + + case "REJECTED": + badgeVariant = "destructive"; + badgeLabel = "거부됨"; + tooltip = "협력업체가 서명을 거부했습니다."; + break; + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <Badge variant={badgeVariant as any} className="ml-2 text-xs"> + {icon} + {badgeLabel} + </Badge> + </TooltipTrigger> + <TooltipContent> + <p>{tooltip}</p> + + {/* 재전송 조건에 계약 유효기간 만료 추가 */} + {(status.isExpired || status.isUpdated || status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired)) && ( + <p className="text-xs mt-1"> + <Button + variant="link" + size="sm" + className="h-4 p-0" + onClick={() => toggleForceResend(vendorId, templateId)} + > + {forceResend.has(`${vendorId}-${templateId}`) ? "재전송 취소" : "재전송 하기"} + </Button> + </p> + )} + </TooltipContent> + </Tooltip> + </TooltipProvider> + ); + }; + + // 유효한 요청인지 확인 함수 개선 + const isValidRequest = (vendorId: number, templateId: number) => { + const status = statusData.find( + s => s.vendorId === vendorId && s.templateId === templateId + ); + + if (!status || status.status === "NONE") return true; + + // 만료되었거나 템플릿이 업데이트되었거나 거부된 경우 재전송 가능 + // 계약 유효기간 만료도 조건에 추가 + if (status.isExpired || + status.isUpdated || + status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired)) { + return forceResend.has(`${vendorId}-${templateId}`); + } + + // PENDING(비만료) 또는 COMPLETED(유효기간 내)는 재전송 불가 + return false; + }; + + + // 요청 발송 처리 + function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + if (selectedTemplateIds.length === 0) { + toast.error("최소 하나 이상의 계약서 템플릿을 선택해주세요.") + return + } + + // 모든 협력업체-템플릿 조합 생성 + const validRequests: { vendorId: number, templateId: number }[] = []; + const skippedRequests: { vendorId: number, templateId: number, reason: string }[] = []; + + vendors.forEach(vendor => { + selectedTemplateIds.forEach(templateId => { + if (isValidRequest(vendor.id, templateId)) { + validRequests.push({ + vendorId: vendor.id, + templateId + }); + } else { + // 유효하지 않은 요청은 건너뜀 + const status = statusData.find( + s => s.vendorId === vendor.id && s.templateId === templateId + ); + + let reason = "알 수 없음"; + if (status) { + if (status.status === "PENDING") reason = "이미 대기 중"; + if (status.status === "COMPLETED") reason = "이미 완료됨"; + } + + skippedRequests.push({ + vendorId: vendor.id, + templateId, + reason + }); + } + }); + }); + + if (validRequests.length === 0) { + toast.error("전송 가능한 요청이 없습니다. 재전송이 필요한 항목을 '재전송 하기' 버튼으로 활성화하세요."); + return; + } + + startRequestTransition(async () => { + // 유효한 요청만 처리 + const requests = validRequests.map(req => + requestBasicContractInfo({ + vendorIds: [req.vendorId], + requestedBy: Number(session.user.id), + templateId: req.templateId + }) + ); + + try { + const results = await Promise.all(requests); + + // 오류 확인 + const errors = results.filter(r => r.error); + if (errors.length > 0) { + toast.error(`${errors.length}개의 요청에서 오류가 발생했습니다.`); + return; + } + + // 상태 메시지 생성 + let successMessage = "기본계약서 서명 요청이 성공적으로 발송되었습니다."; + if (skippedRequests.length > 0) { + successMessage += ` (${skippedRequests.length}개 요청 건너뜀)`; + } + + props.onOpenChange?.(false); + toast.success(successMessage); + onSuccess?.(); + } catch (error) { + console.error("요청 처리 중 오류:", error); + toast.error("서명 요청 처리 중 오류가 발생했습니다."); + } + }); + } + + // 선택된 템플릿 수 표시 + const selectedCount = selectedTemplateIds.length; + const totalCount = templates.length; + + // UI 렌더링 + const renderTemplateList = () => ( + <div className="space-y-3"> + {templates.map((template) => ( + <div key={template.id} className="pb-2 border-b last:border-b-0"> + <div className="flex items-center justify-between"> + <div className="flex items-center space-x-2"> + <Checkbox + id={`template-${template.id}`} + checked={selectedTemplateIds.includes(template.id)} + onCheckedChange={(checked) => handleTemplateToggle(template.id, checked as boolean)} + /> + <label + htmlFor={`template-${template.id}`} + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer" + > + {template.templateName} + </label> + + {/* 상태 배지를 템플릿 이름 옆에 나란히 배치 */} + {vendors.length === 1 && renderStatusBadge(vendors[0].id, template.id)} + </div> + + + {vendors.length === 1 && (() => { + const status = statusData.find( + s => s.vendorId === vendors[0].id && s.templateId === template.id + ); + + // 계약 유효기간 만료 조건 추가 + if (status && (status.isExpired || + status.isUpdated || + status.status === "REJECTED" || + (status.status === "COMPLETED" && status.isContractExpired))) { + const key = `${vendors[0].id}-${template.id}`; + + // 계약 유효기간 만료인 경우 다른 텍스트 표시 + const buttonText = status.status === "COMPLETED" && status.isContractExpired + ? (forceResend.has(key) ? "재계약 취소" : "재계약하기") + : (forceResend.has(key) ? "재전송 취소" : "재전송하기"); + + return ( + <Button + variant="ghost" + size="sm" + className="h-7 px-2 text-xs" + onClick={() => toggleForceResend(vendors[0].id, template.id)} + > + {buttonText} + </Button> + ); + } + return null; + })()} + + </div> + + {/* 추가 정보 표시 (파일명 등) */} + <div className="mt-1 pl-6 text-xs text-muted-foreground"> + {template.fileName} + </div> + </div> + ))} + </div> + ); + + // 내용 영역 렌더링 + const renderContentArea = () => ( + <div className="space-y-4"> + <div className="space-y-2"> + <div className="flex justify-between items-center"> + <h3 className="text-sm font-medium">계약서 템플릿 선택</h3> + <span className="text-xs text-muted-foreground"> + {selectedCount}/{totalCount} 선택됨 + </span> + </div> + + {isLoading ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="size-4 animate-spin mr-2" /> + <span>템플릿 로딩 중...</span> + </div> + ) : templates.length === 0 ? ( + <div className="text-sm text-muted-foreground p-2 border rounded-md"> + 활성 상태의 템플릿이 없습니다. 템플릿을 먼저 등록해주세요. + </div> + ) : ( + // ScrollArea 대신 네이티브 스크롤 사용 + <div className="border rounded-md p-3 overflow-y-auto h-[200px]"> + {renderTemplateList()} + </div> + )} + </div> + + {statusLoading && ( + <div className="flex items-center text-sm text-muted-foreground"> + <Loader className="size-3 animate-spin mr-2" /> + <span>계약 상태 확인 중...</span> + </div> + )} + + {/* 선택된 템플릿 정보 (ScrollArea 대신 네이티브 스크롤 사용) */} + {selectedTemplateIds.length > 0 && ( + <div className="space-y-2 text-sm"> + <h3 className="font-medium">선택된 템플릿 정보</h3> + <div className="overflow-y-auto max-h-[150px] border rounded-md p-2"> + <div className="space-y-2"> + {selectedTemplateIds.map(id => { + const template = templates.find(t => t.id === id); + if (!template) return null; + + return ( + <div key={id} className="p-2 border rounded-md bg-muted/50"> + <p><span className="font-medium">이름:</span> {template.templateName}</p> + <p><span className="font-medium">파일:</span> {template.fileName}</p> + </div> + ); + })} + </div> + </div> + </div> + )} + + <div className="text-sm text-muted-foreground mt-4"> + 요청시 협력업체에게 이메일이 발송되며, 협력업체는 별도 페이지에서 기본계약서와 기타 관련 서류들에 대해서 서명을 하게 됩니다. + </div> + </div> + ); + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + 기본계약서 서명 요청 ({vendors.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>협력업체 기본계약서 요청 확인</DialogTitle> + <DialogDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까? + </DialogDescription> + </DialogHeader> + + <div className="py-4"> + {renderContentArea()} + </div> + + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="Send request to selected vendors" + variant="default" + onClick={onApprove} + disabled={isRequestPending || isLoading || selectedTemplateIds.length === 0} + > + {isRequestPending && ( + <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" className="gap-2"> + <Send className="size-4" aria-hidden="true" /> + 기본계약서 서명 요청 ({vendors.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>협력업체 기본계약서 요청 확인</DrawerTitle> + <DrawerDescription> + <span className="font-medium">{vendors.length}</span> + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 기본계약서 서명을 요청하시겠습니까? + </DrawerDescription> + </DrawerHeader> + + <div className="px-4"> + {renderContentArea()} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="Send request to selected vendors" + variant="default" + onClick={onApprove} + disabled={isRequestPending || isLoading || selectedTemplateIds.length === 0} + > + {isRequestPending && ( + <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/vendors/table/request-project-pq-dialog.tsx b/lib/vendors/table/request-project-pq-dialog.tsx index c590d7ec..a9fe0e1a 100644 --- a/lib/vendors/table/request-project-pq-dialog.tsx +++ b/lib/vendors/table/request-project-pq-dialog.tsx @@ -44,6 +44,7 @@ import { Label } from "@/components/ui/label" import { Vendor } from "@/db/schema/vendors" import { requestPQVendors } from "../service" import { getProjects, type Project } from "@/lib/rfqs/service" +import { useSession } from "next-auth/react" interface RequestProjectPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -63,6 +64,7 @@ export function RequestProjectPQDialog({ const [projects, setProjects] = React.useState<Project[]>([]) const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null) const [isLoadingProjects, setIsLoadingProjects] = React.useState(false) + const { data: session } = useSession() // 프로젝트 목록 로드 React.useEffect(() => { @@ -95,15 +97,23 @@ export function RequestProjectPQDialog({ } function onApprove() { + if (!selectedProjectId) { toast.error("프로젝트를 선택해주세요.") return } + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startApproveTransition(async () => { const { error } = await requestPQVendors({ ids: vendors.map((vendor) => vendor.id), projectId: selectedProjectId, + userId: Number(session.user.id) + }) if (error) { @@ -113,7 +123,7 @@ export function RequestProjectPQDialog({ props.onOpenChange?.(false) - toast.success(`벤더에게 프로젝트 PQ가 성공적으로 요청되었습니다.`) + toast.success(`협력업체에게 프로젝트 PQ가 성공적으로 요청되었습니다.`) onSuccess?.() }) } @@ -165,8 +175,8 @@ export function RequestProjectPQDialog({ <DialogTitle>프로젝트 PQ 요청 확인</DialogTitle> <DialogDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? - 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. </DialogDescription> </DialogHeader> @@ -177,7 +187,7 @@ export function RequestProjectPQDialog({ <Button variant="outline">취소</Button> </DialogClose> <Button - aria-label="선택한 벤더에게 요청하기" + aria-label="선택한 협력업체에게 요청하기" variant="default" onClick={onApprove} disabled={isApprovePending || !selectedProjectId} @@ -211,8 +221,8 @@ export function RequestProjectPQDialog({ <DrawerTitle>프로젝트 PQ 요청 확인</DrawerTitle> <DrawerDescription> <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? "개의 벤더" : "개의 벤더들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? - 요청을 보내면 벤더에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. + {vendors.length === 1 ? "개의 협력업체" : "개의 협력업체들"}에게 프로젝트 PQ 제출을 요청하시겠습니까? + 요청을 보내면 협력업체에게 알림이 발송되고 프로젝트 PQ 정보를 입력할 수 있게 됩니다. </DrawerDescription> </DrawerHeader> @@ -225,7 +235,7 @@ export function RequestProjectPQDialog({ <Button variant="outline">취소</Button> </DrawerClose> <Button - aria-label="선택한 벤더에게 요청하기" + aria-label="선택한 협력업체에게 요청하기" variant="default" onClick={onApprove} disabled={isApprovePending || !selectedProjectId} diff --git a/lib/vendors/table/request-vendor-investigate-dialog.tsx b/lib/vendors/table/request-vendor-investigate-dialog.tsx index 0309ee4a..b3deafce 100644 --- a/lib/vendors/table/request-vendor-investigate-dialog.tsx +++ b/lib/vendors/table/request-vendor-investigate-dialog.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Row } from "@tanstack/react-table" -import { Loader, Check, SendHorizonal } from "lucide-react" +import { Loader, Check, SendHorizonal, AlertCircle, AlertTriangle } from "lucide-react" import { toast } from "sonner" import { useMediaQuery } from "@/hooks/use-media-query" @@ -27,8 +27,29 @@ import { DrawerTitle, DrawerTrigger, } from "@/components/ui/drawer" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion" +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" + import { Vendor } from "@/db/schema/vendors" -import { requestInvestigateVendors } from "@/lib/vendor-investigation/service" +import { requestInvestigateVendors, getExistingInvestigationsForVendors } from "@/lib/vendor-investigation/service" +import { useSession } from "next-auth/react" +import { formatDate } from "@/lib/utils" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -37,21 +58,98 @@ interface ApprovalVendorDialogProps onSuccess?: () => void } +// Helper function to get status badge variant and text +function getStatusBadge(status: string) { + switch (status) { + case "REQUESTED": + return { variant: "secondary", text: "Requested" } + case "SCHEDULED": + return { variant: "warning", text: "Scheduled" } + case "IN_PROGRESS": + return { variant: "default", text: "In Progress" } + case "COMPLETED": + return { variant: "success", text: "Completed" } + case "CANCELLED": + return { variant: "destructive", text: "Cancelled" } + default: + return { variant: "outline", text: status } + } +} + export function RequestVendorsInvestigateDialog({ vendors, showTrigger = true, onSuccess, ...props }: ApprovalVendorDialogProps) { - - console.log(vendors) const [isApprovePending, startApproveTransition] = React.useTransition() + const [isLoading, setIsLoading] = React.useState(true) + const [existingInvestigations, setExistingInvestigations] = React.useState<any[]>([]) const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() + + // Fetch existing investigations when dialog opens + React.useEffect(() => { + if (vendors.length > 0) { + setIsLoading(true) + const fetchExistingInvestigations = async () => { + try { + const vendorIds = vendors.map(vendor => vendor.id) + const result = await getExistingInvestigationsForVendors(vendorIds) + setExistingInvestigations(result) + } catch (error) { + console.error("Failed to fetch existing investigations:", error) + toast.error("Failed to fetch existing investigations") + } finally { + setIsLoading(false) + } + } + + fetchExistingInvestigations() + } + }, [vendors]) + + // Group vendors by investigation status + const vendorsWithInvestigations = React.useMemo(() => { + if (!existingInvestigations.length) return { withInvestigations: [], withoutInvestigations: vendors } + + const vendorMap = new Map(vendors.map(v => [v.id, v])) + const withInvestigations: Array<{ vendor: typeof vendors[0], investigation: any }> = [] + + // Find vendors with existing investigations + existingInvestigations.forEach(inv => { + const vendor = vendorMap.get(inv.vendorId) + if (vendor) { + withInvestigations.push({ vendor, investigation: inv }) + vendorMap.delete(inv.vendorId) + } + }) + + // Remaining vendors don't have investigations + const withoutInvestigations = Array.from(vendorMap.values()) + + return { withInvestigations, withoutInvestigations } + }, [vendors, existingInvestigations]) function onApprove() { + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + // Only request investigations for vendors without existing ones + const vendorsToRequest = vendorsWithInvestigations.withoutInvestigations + + if (vendorsToRequest.length === 0) { + toast.info("모든 선택된 업체에 이미 실사 요청이 있습니다.") + props.onOpenChange?.(false) + return + } + startApproveTransition(async () => { const { error } = await requestInvestigateVendors({ - ids: vendors.map((vendor) => vendor.id), + ids: vendorsToRequest.map((vendor) => vendor.id), + userId: Number(session.user.id) }) if (error) { @@ -60,11 +158,102 @@ export function RequestVendorsInvestigateDialog({ } props.onOpenChange?.(false) - toast.success("Vendor Investigation successfully sent to 벤더실사담당자") + toast.success(`${vendorsToRequest.length}개 업체에 대한 실사 요청을 보냈습니다.`) onSuccess?.() }) } + const renderContent = () => { + return ( + <> + <div className="space-y-4"> + {isLoading ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="size-6 animate-spin text-muted-foreground" /> + </div> + ) : ( + <> + {vendorsWithInvestigations.withInvestigations.length > 0 && ( + <Alert> + <AlertTriangle className="h-4 w-4" /> + <AlertTitle>기존 실사 요청 정보가 있습니다</AlertTitle> + <AlertDescription> + 선택한 {vendors.length}개 업체 중 {vendorsWithInvestigations.withInvestigations.length}개 업체에 대한 + 기존 실사 요청이 있습니다. 새로운 요청은 기존 데이터가 없는 업체에만 적용됩니다. + </AlertDescription> + </Alert> + )} + + {vendorsWithInvestigations.withInvestigations.length > 0 && ( + <Accordion type="single" collapsible className="w-full"> + <AccordionItem value="existing-investigations"> + <AccordionTrigger className="font-medium"> + 기존 실사 요청 ({vendorsWithInvestigations.withInvestigations.length}) + </AccordionTrigger> + <AccordionContent> + <ScrollArea className="max-h-[200px]"> + <Table> + <TableHeader> + <TableRow> + <TableHead>업체명</TableHead> + <TableHead>상태</TableHead> + <TableHead>요청일</TableHead> + <TableHead>예정 일정</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {vendorsWithInvestigations.withInvestigations.map(({ vendor, investigation }) => { + const status = getStatusBadge(investigation.investigationStatus) + return ( + <TableRow key={investigation.investigationId}> + <TableCell className="font-medium">{vendor.vendorName}</TableCell> + <TableCell> + <Badge variant={status.variant as any}>{status.text}</Badge> + </TableCell> + <TableCell>{formatDate(investigation.createdAt)}</TableCell> + <TableCell> + {investigation.scheduledStartAt + ? formatDate(investigation.scheduledStartAt) + : "미정"} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </ScrollArea> + </AccordionContent> + </AccordionItem> + </Accordion> + )} + + <div> + <h3 className="text-sm font-medium mb-2"> + 새로운 실사가 요청될 업체 ({vendorsWithInvestigations.withoutInvestigations.length}) + </h3> + {vendorsWithInvestigations.withoutInvestigations.length > 0 ? ( + <ScrollArea className="max-h-[200px]"> + <ul className="space-y-1"> + {vendorsWithInvestigations.withoutInvestigations.map((vendor) => ( + <li key={vendor.id} className="text-sm py-1 px-2 border-b"> + {vendor.vendorName} ({vendor.vendorCode || "코드 없음"}) + </li> + ))} + </ul> + </ScrollArea> + ) : ( + <p className="text-sm text-muted-foreground py-2"> + 모든 선택된 업체에 이미 실사 요청이 있습니다. + </p> + )} + </div> + </> + )} + </div> + </> + ) + } + if (isDesktop) { return ( <Dialog {...props}> @@ -72,29 +261,30 @@ export function RequestVendorsInvestigateDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm" className="gap-2"> <SendHorizonal className="size-4" aria-hidden="true" /> - Vendor Investigation Request ({vendors.length}) + 실사 요청 ({vendors.length}) </Button> </DialogTrigger> ) : null} - <DialogContent> + <DialogContent className="max-w-md"> <DialogHeader> - <DialogTitle>Confirm Vendor Investigation Requst</DialogTitle> + <DialogTitle>Confirm Vendor Investigation Request</DialogTitle> <DialogDescription> - Are you sure you want to request{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}? - After sent, 벤더실사담당자 will be notified and can manage it. + 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다. + 요청 후 협력업체실사담당자에게 알림이 전송됩니다. </DialogDescription> </DialogHeader> - <DialogFooter className="gap-2 sm:space-x-0"> + + {renderContent()} + + <DialogFooter className="gap-2 sm:space-x-0 mt-4"> <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <Button variant="outline">취소</Button> </DialogClose> <Button aria-label="Request selected vendors" variant="default" onClick={onApprove} - disabled={isApprovePending} + disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0} > {isApprovePending && ( <Loader @@ -102,7 +292,7 @@ export function RequestVendorsInvestigateDialog({ aria-hidden="true" /> )} - Request + 요청하기 </Button> </DialogFooter> </DialogContent> @@ -124,26 +314,29 @@ export function RequestVendorsInvestigateDialog({ <DrawerHeader> <DrawerTitle>Confirm Vendor Investigation</DrawerTitle> <DrawerDescription> - Are you sure you want to request{" "} - <span className="font-medium">{vendors.length}</span> - {vendors.length === 1 ? " vendor" : " vendors"}? - After sent, 벤더실사담당자 will be notified and can manage it. + 선택한 {vendors.length}개 업체에 대한 실사 요청을 확인합니다. + 요청 후 협력업체실사담당자에게 알림이 전송됩니다. </DrawerDescription> </DrawerHeader> - <DrawerFooter className="gap-2 sm:space-x-0"> + + <div className="px-4"> + {renderContent()} + </div> + + <DrawerFooter className="gap-2 sm:space-x-0 mt-4"> <DrawerClose asChild> - <Button variant="outline">Cancel</Button> + <Button variant="outline">취소</Button> </DrawerClose> <Button aria-label="Request selected vendors" variant="default" onClick={onApprove} - disabled={isApprovePending} + disabled={isApprovePending || isLoading || vendorsWithInvestigations.withoutInvestigations.length === 0} > {isApprovePending && ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> )} - Request + 요청하기 </Button> </DrawerFooter> </DrawerContent> diff --git a/lib/vendors/table/request-vendor-pg-dialog.tsx b/lib/vendors/table/request-vendor-pg-dialog.tsx index de23ad9b..4bc4e909 100644 --- a/lib/vendors/table/request-vendor-pg-dialog.tsx +++ b/lib/vendors/table/request-vendor-pg-dialog.tsx @@ -29,6 +29,7 @@ import { } from "@/components/ui/drawer" import { Vendor } from "@/db/schema/vendors" import { requestPQVendors } from "../service" +import { useSession } from "next-auth/react" interface ApprovalVendorDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> { @@ -45,11 +46,21 @@ export function RequestPQVendorsDialog({ }: ApprovalVendorDialogProps) { const [isApprovePending, startApproveTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + const { data: session } = useSession() function onApprove() { + + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + + startApproveTransition(async () => { const { error } = await requestPQVendors({ ids: vendors.map((vendor) => vendor.id), + userId: Number(session.user.id) + }) if (error) { diff --git a/lib/vendors/table/update-vendor-sheet.tsx b/lib/vendors/table/update-vendor-sheet.tsx index e65c4b1c..08994b6a 100644 --- a/lib/vendors/table/update-vendor-sheet.tsx +++ b/lib/vendors/table/update-vendor-sheet.tsx @@ -3,7 +3,25 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { Loader } from "lucide-react" +import { + Loader, + Activity, + AlertCircle, + AlertTriangle, + ClipboardList, + FilePenLine, + XCircle, + ClipboardCheck, + FileCheck2, + FileX2, + BadgeCheck, + CheckCircle2, + Circle as CircleIcon, + User, + Building, + AlignLeft, + Calendar +} from "lucide-react" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -14,6 +32,7 @@ import { FormItem, FormLabel, FormMessage, + FormDescription } from "@/components/ui/form" import { Input } from "@/components/ui/input" import { @@ -33,27 +52,143 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { useSession } from "next-auth/react" // Import useSession -import { Vendor } from "@/db/schema/vendors" +import { VendorWithType, vendors } from "@/db/schema/vendors" import { updateVendorSchema, type UpdateVendorSchema } from "../validations" import { modifyVendor } from "../service" -// 예: import { modifyVendor } from "@/lib/vendors/service" interface UpdateVendorSheetProps extends React.ComponentPropsWithRef<typeof Sheet> { - vendor: Vendor | null + vendor: VendorWithType | null +} +type StatusType = (typeof vendors.status.enumValues)[number]; + +type StatusConfig = { + Icon: React.ElementType; + className: string; + label: string; +}; + +// 상태 표시 유틸리티 함수 +const getStatusConfig = (status: StatusType): StatusConfig => { + switch(status) { + case "PENDING_REVIEW": + return { + Icon: ClipboardList, + className: "text-yellow-600", + label: "가입 신청 중" + }; + case "IN_REVIEW": + return { + Icon: FilePenLine, + className: "text-blue-600", + label: "심사 중" + }; + case "REJECTED": + return { + Icon: XCircle, + className: "text-red-600", + label: "심사 거부됨" + }; + case "IN_PQ": + return { + Icon: ClipboardCheck, + className: "text-purple-600", + label: "PQ 진행 중" + }; + case "PQ_SUBMITTED": + return { + Icon: FileCheck2, + className: "text-indigo-600", + label: "PQ 제출" + }; + case "PQ_FAILED": + return { + Icon: FileX2, + className: "text-red-600", + label: "PQ 실패" + }; + case "PQ_APPROVED": + return { + Icon: BadgeCheck, + className: "text-green-600", + label: "PQ 통과" + }; + case "APPROVED": + return { + Icon: CheckCircle2, + className: "text-green-600", + label: "승인됨" + }; + case "READY_TO_SEND": + return { + Icon: CheckCircle2, + className: "text-emerald-600", + label: "MDG 송부대기" + }; + case "ACTIVE": + return { + Icon: Activity, + className: "text-emerald-600", + label: "활성 상태" + }; + case "INACTIVE": + return { + Icon: AlertCircle, + className: "text-gray-600", + label: "비활성 상태" + }; + case "BLACKLISTED": + return { + Icon: AlertTriangle, + className: "text-slate-800", + label: "거래 금지" + }; + default: + return { + Icon: CircleIcon, + className: "text-gray-600", + label: status + }; + } +}; + +// 신용평가기관 목록 +const creditAgencies = [ + { value: "NICE", label: "NICE평가정보" }, + { value: "KIS", label: "KIS (한국신용평가)" }, + { value: "KED", label: "KED (한국기업데이터)" }, + { value: "SCI", label: "SCI평가정보" }, +] + +// 신용등급 스케일 +const creditRatingScaleMap: Record<string, string[]> = { + NICE: ["AAA", "AA", "A", "BBB", "BB", "B", "C", "D"], + KIS: ["AAA", "AA+", "AA", "A+", "A", "BBB+", "BBB", "BB", "B", "C"], + KED: ["AAA", "AA", "A", "BBB", "BB", "B", "CCC", "CC", "C", "D"], + SCI: ["AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB-", "B"], +} + +// 현금흐름등급 스케일 +const cashFlowRatingScaleMap: Record<string, string[]> = { + NICE: ["우수", "양호", "보통", "미흡", "불량"], + KIS: ["A+", "A", "B+", "B", "C", "D"], + KED: ["1등급", "2등급", "3등급", "4등급", "5등급"], + SCI: ["Level 1", "Level 2", "Level 3", "Level 4"], } // 폼 컴포넌트 export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) { const [isPending, startTransition] = React.useTransition() + const [selectedAgency, setSelectedAgency] = React.useState<string>(vendor?.creditAgency || "NICE") - console.log(vendor) - - // RHF + Zod + // 폼 정의 - UpdateVendorSchema 타입을 직접 사용 const form = useForm<UpdateVendorSchema>({ resolver: zodResolver(updateVendorSchema), defaultValues: { + // 업체 기본 정보 vendorName: vendor?.vendorName ?? "", vendorCode: vendor?.vendorCode ?? "", address: vendor?.address ?? "", @@ -61,7 +196,18 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) phone: vendor?.phone ?? "", email: vendor?.email ?? "", website: vendor?.website ?? "", + creditRating: vendor?.creditRating ?? "", + cashFlowRating: vendor?.cashFlowRating ?? "", status: vendor?.status ?? "ACTIVE", + vendorTypeId: vendor?.vendorTypeId ?? undefined, + + // 구매담당자 정보 (기본값은 비어있음) + buyerName: "", + buyerDepartment: "", + contractStartDate: undefined, + contractEndDate: undefined, + internalNotes: "", + // evaluationScore: "", }, }) @@ -75,191 +221,439 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps) phone: vendor?.phone ?? "", email: vendor?.email ?? "", website: vendor?.website ?? "", + creditRating: vendor?.creditRating ?? "", + cashFlowRating: vendor?.cashFlowRating ?? "", status: vendor?.status ?? "ACTIVE", + vendorTypeId: vendor?.vendorTypeId ?? undefined, + + // 구매담당자 필드는 유지 + buyerName: form.getValues("buyerName"), + buyerDepartment: form.getValues("buyerDepartment"), + contractStartDate: form.getValues("contractStartDate"), + contractEndDate: form.getValues("contractEndDate"), + internalNotes: form.getValues("internalNotes"), + // evaluationScore: form.getValues("evaluationScore"), }); } }, [vendor, form]); - console.log(form.getValues()) + // 신용평가기관 변경 시 등급 필드를 초기화하는 효과 + React.useEffect(() => { + // 선택된 평가기관에 따라 현재 선택된 등급이 유효한지 확인 + const currentCreditRating = form.getValues("creditRating"); + const currentCashFlowRating = form.getValues("cashFlowRating"); + + // 선택된 기관에 따른 유효한 등급 목록 + const validCreditRatings = creditRatingScaleMap[selectedAgency] || []; + const validCashFlowRatings = cashFlowRatingScaleMap[selectedAgency] || []; + + // 현재 등급이 유효하지 않으면 초기화 + if (currentCreditRating && !validCreditRatings.includes(currentCreditRating)) { + form.setValue("creditRating", ""); + } + + if (currentCashFlowRating && !validCashFlowRatings.includes(currentCashFlowRating)) { + form.setValue("cashFlowRating", ""); + } + + // 신용평가기관 필드 업데이트 + if(selectedAgency){ + form.setValue("creditAgency", selectedAgency as "NICE" | "KIS" | "KED" | "SCI"); + } + + }, [selectedAgency, form]); + // 제출 핸들러 async function onSubmit(data: UpdateVendorSchema) { if (!vendor) return + const { data: session } = useSession() - startTransition(async () => { - // 서버 액션 or API - // const { error } = await modifyVendor({ id: vendor.id, ...data }) - // 여기선 간단 예시 - try { - // 예시: - const { error } = await modifyVendor({ id: String(vendor.id), ...data }) - if (error) throw new Error(error) - - toast.success("Vendor updated!") - form.reset() - props.onOpenChange?.(false) - } catch (err: any) { - toast.error(String(err)) - } - }) - } + if (!session?.user?.id) { + toast.error("사용자 인증 정보를 찾을 수 없습니다.") + return + } + startTransition(async () => { + try { + // Add status change comment if status has changed + const oldStatus = vendor.status ?? "ACTIVE" // Default to ACTIVE if undefined + const newStatus = data.status ?? "ACTIVE" // Default to ACTIVE if undefined + + const statusComment = + oldStatus !== newStatus + ? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}` + : "" // Empty string instead of undefined + + // 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가 + const { error } = await modifyVendor({ + id: String(vendor.id), + userId: Number(session.user.id), // Add user ID from session + comment: statusComment, // Add comment for status changes + ...data // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리 + }) + + if (error) throw new Error(error) + + toast.success("업체 정보가 업데이트되었습니다!") + form.reset() + props.onOpenChange?.(false) + } catch (err: any) { + toast.error(String(err)) + } + }) +} return ( <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetContent className="flex flex-col gap-6 sm:max-w-lg overflow-y-auto"> <SheetHeader className="text-left"> - <SheetTitle>Update Vendor</SheetTitle> + <SheetTitle>업체 정보 수정</SheetTitle> <SheetDescription> - Update the vendor details and save the changes + 업체 세부 정보를 수정하고 변경 사항을 저장하세요 </SheetDescription> </SheetHeader> <Form {...form}> - <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4"> - {/* vendorName */} - <FormField - control={form.control} - name="vendorName" - render={({ field }) => ( - <FormItem> - <FormLabel>Vendor Name</FormLabel> - <FormControl> - <Input placeholder="Vendor Name" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-6"> + {/* 업체 기본 정보 섹션 */} + <div className="space-y-4"> + <div className="flex items-center"> + <Building className="mr-2 h-5 w-5 text-muted-foreground" /> + <h3 className="text-sm font-medium">업체 기본 정보</h3> + </div> + <FormDescription> + 업체가 제공한 기본 정보입니다. 필요시 수정하세요. + </FormDescription> + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + {/* vendorName */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>업체명</FormLabel> + <FormControl> + <Input placeholder="업체명 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* vendorCode */} + <FormField + control={form.control} + name="vendorCode" + render={({ field }) => ( + <FormItem> + <FormLabel>업체 코드</FormLabel> + <FormControl> + <Input placeholder="예: ABC123" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* address */} + <FormField + control={form.control} + name="address" + render={({ field }) => ( + <FormItem className="md:col-span-2"> + <FormLabel>주소</FormLabel> + <FormControl> + <Input placeholder="주소 입력" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* country */} + <FormField + control={form.control} + name="country" + render={({ field }) => ( + <FormItem> + <FormLabel>국가</FormLabel> + <FormControl> + <Input placeholder="예: 대한민국" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* phone */} + <FormField + control={form.control} + name="phone" + render={({ field }) => ( + <FormItem> + <FormLabel>전화번호</FormLabel> + <FormControl> + <Input placeholder="예: 010-1234-5678" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* email */} + <FormField + control={form.control} + name="email" + render={({ field }) => ( + <FormItem> + <FormLabel>이메일</FormLabel> + <FormControl> + <Input placeholder="예: info@company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* website */} + <FormField + control={form.control} + name="website" + render={({ field }) => ( + <FormItem> + <FormLabel>웹사이트</FormLabel> + <FormControl> + <Input placeholder="예: https://www.company.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* vendorCode */} - <FormField - control={form.control} - name="vendorCode" - render={({ field }) => ( - <FormItem> - <FormLabel>Vendor Code</FormLabel> - <FormControl> - <Input placeholder="Code123" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* status with icons */} + <FormField + control={form.control} + name="status" + render={({ field }) => { + // 현재 선택된 상태의 구성 정보 가져오기 + const selectedConfig = getStatusConfig(field.value ?? "ACTIVE"); + const SelectedIcon = selectedConfig?.Icon || CircleIcon; - {/* address */} - <FormField - control={form.control} - name="address" - render={({ field }) => ( - <FormItem> - <FormLabel>Address</FormLabel> - <FormControl> - <Input placeholder="123 Main St" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + return ( + <FormItem> + <FormLabel>업체승인상태</FormLabel> + <FormControl> + <Select + value={field.value} + onValueChange={field.onChange} + > + <SelectTrigger className="w-full"> + <SelectValue> + {field.value && ( + <div className="flex items-center"> + <SelectedIcon className={`mr-2 h-4 w-4 ${selectedConfig.className}`} /> + <span>{selectedConfig.label}</span> + </div> + )} + </SelectValue> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {vendors.status.enumValues.map((status) => { + const config = getStatusConfig(status); + const StatusIcon = config.Icon; + return ( + <SelectItem key={status} value={status}> + <div className="flex items-center"> + <StatusIcon className={`mr-2 h-4 w-4 ${config.className}`} /> + <span>{config.label}</span> + </div> + </SelectItem> + ); + })} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ); + }} + /> - {/* country */} - <FormField - control={form.control} - name="country" - render={({ field }) => ( - <FormItem> - <FormLabel>Country</FormLabel> - <FormControl> - <Input placeholder="USA" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + + {/* 신용평가기관 선택 */} + <FormField + control={form.control} + name="creditAgency" + render={({ field }) => ( + <FormItem> + <FormLabel>신용평가기관</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={(value) => { + field.onChange(value); + setSelectedAgency(value); + }} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="평가기관 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {creditAgencies.map((agency) => ( + <SelectItem key={agency.value} value={agency.value}> + {agency.label} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가년도 - 나중에 추가 가능 */} + + {/* 신용등급 - 선택된 기관에 따라 옵션 변경 */} + <FormField + control={form.control} + name="creditRating" + render={({ field }) => ( + <FormItem> + <FormLabel>신용등급</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={field.onChange} + disabled={!selectedAgency} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="신용등급 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {(creditRatingScaleMap[selectedAgency] || []).map((rating) => ( + <SelectItem key={rating} value={rating}> + {rating} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 현금흐름등급 - 선택된 기관에 따라 옵션 변경 */} + <FormField + control={form.control} + name="cashFlowRating" + render={({ field }) => ( + <FormItem> + <FormLabel>현금흐름등급</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={field.onChange} + disabled={!selectedAgency} + > + <SelectTrigger className="w-full"> + <SelectValue placeholder="현금흐름등급 선택" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + {(cashFlowRatingScaleMap[selectedAgency] || []).map((rating) => ( + <SelectItem key={rating} value={rating}> + {rating} + </SelectItem> + ))} + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* phone */} - <FormField - control={form.control} - name="phone" - render={({ field }) => ( - <FormItem> - <FormLabel>Phone</FormLabel> - <FormControl> - <Input placeholder="+1 555-1234" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + </div> + </div> - {/* email */} - <FormField - control={form.control} - name="email" - render={({ field }) => ( - <FormItem> - <FormLabel>Email</FormLabel> - <FormControl> - <Input placeholder="vendor@example.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* 구분선 */} + <Separator className="my-2" /> - {/* website */} - <FormField - control={form.control} - name="website" - render={({ field }) => ( - <FormItem> - <FormLabel>Website</FormLabel> - <FormControl> - <Input placeholder="https://www.vendor.com" {...field} /> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + {/* 구매담당자 입력 섹션 */} + <div className="space-y-4 bg-slate-50 p-4 rounded-md border border-slate-200"> + <div className="flex items-center"> + <User className="mr-2 h-5 w-5 text-blue-600" /> + <h3 className="text-sm font-medium text-blue-800">구매담당자 정보</h3> + </div> + <FormDescription> + 구매담당자가 관리하는 추가 정보입니다. 이 정보는 내부용으로만 사용됩니다. + </FormDescription> + + <div className="grid grid-cols-1 gap-4 md:grid-cols-2"> + {/* 여기에 구매담당자 필드 추가 */} + <FormField + control={form.control} + name="buyerName" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 이름</FormLabel> + <FormControl> + <Input placeholder="담당자 이름" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="buyerDepartment" + render={({ field }) => ( + <FormItem> + <FormLabel>담당 부서</FormLabel> + <FormControl> + <Input placeholder="예: 구매부" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> - {/* status */} - <FormField - control={form.control} - name="status" - render={({ field }) => ( - <FormItem> - <FormLabel>Status</FormLabel> - <FormControl> - <Select - value={field.value} - onValueChange={field.onChange} - > - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select a status" /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - {/* enum ["ACTIVE","INACTIVE","BLACKLISTED"] */} - <SelectItem value="ACTIVE">ACTIVE</SelectItem> - <SelectItem value="INACTIVE">INACTIVE</SelectItem> - <SelectItem value="BLACKLISTED">BLACKLISTED</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - </FormControl> - <FormMessage /> - </FormItem> - )} - /> + + <FormField + control={form.control} + name="internalNotes" + render={({ field }) => ( + <FormItem className="md:col-span-2"> + <FormLabel>내부 메모</FormLabel> + <FormControl> + <Input placeholder="내부 참고사항을 입력하세요" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> <SheetFooter className="gap-2 pt-2 sm:space-x-0"> <SheetClose asChild> <Button type="button" variant="outline"> - Cancel + 취소 </Button> </SheetClose> <Button disabled={isPending}> {isPending && ( <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> )} - Save + 저장 </Button> </SheetFooter> </form> diff --git a/lib/vendors/table/vendor-all-export.ts b/lib/vendors/table/vendor-all-export.ts new file mode 100644 index 00000000..cef801fd --- /dev/null +++ b/lib/vendors/table/vendor-all-export.ts @@ -0,0 +1,486 @@ +// /lib/vendor-export.ts +import ExcelJS from "exceljs" +import { VendorWithType } from "@/db/schema/vendors" +import { exportVendorDetails } from "../service"; + +// 연락처 인터페이스 정의 +interface VendorContact { + contactName: string; + contactPosition?: string | null; + contactEmail: string; + contactPhone?: string | null; + isPrimary: boolean; +} + +// 아이템 인터페이스 정의 +interface VendorItem { + itemCode: string; + itemName: string; + description?: string | null; + createdAt?: Date | string; +} + +// RFQ 인터페이스 정의 +interface VendorRFQ { + rfqNumber: string; + title: string; + status: string; + requestDate?: Date | string | null; + dueDate?: Date | string | null; + description?: string | null; +} + +// 계약 인터페이스 정의 +interface VendorContract { + projectCode: string; + projectName: string; + contractNo: string; + contractName: string; + status: string; + paymentTerms: string; + deliveryTerms: string; + deliveryDate: Date | string; + deliveryLocation: string; + startDate?: Date | string | null; + endDate?: Date | string | null; + currency: string; + totalAmount?: number | null; +} + +// 서비스에서 반환하는 실제 데이터 구조 +interface VendorData { + id: number; + vendorName: string; + vendorCode: string | null; + taxId: string; + address: string | null; + country: string | null; + phone: string | null; + email: string | null; + website: string | null; + status: string; + representativeName: string | null; + representativeBirth: string | null; + representativeEmail: string | null; + representativePhone: string | null; + corporateRegistrationNumber: string | null; + creditAgency: string | null; + creditRating: string | null; + cashFlowRating: string | null; +// items: string | null; + createdAt: Date; + updatedAt: Date; + vendorContacts: VendorContact[]; + vendorItems: VendorItem[]; + vendorRfqs: VendorRFQ[]; + vendorContracts: VendorContract[]; +} + +/** + * 선택된 벤더의 모든 관련 정보를 통합 시트 형식으로 엑셀로 내보내는 함수 + * - 기본정보 시트 + * - 연락처 시트 + * - 아이템 시트 + * - RFQ 시트 + * - 계약 시트 + * 각 시트에는 식별을 위한 벤더 코드, 벤더명, 세금ID가 포함됨 + */ +export async function exportVendorsWithRelatedData( + vendors: VendorWithType[], + filename = "vendors-detailed" +): Promise<void> { + if (!vendors.length) return; + + // 선택된 벤더 ID 목록 + const vendorIds = vendors.map(vendor => vendor.id); + + try { + // 서버로부터 모든 관련 데이터 가져오기 + const vendorsWithDetails = await exportVendorDetails(vendorIds); + + if (!vendorsWithDetails.length) { + throw new Error("내보내기 데이터를 가져오는 중 오류가 발생했습니다."); + } + + // 워크북 생성 + const workbook = new ExcelJS.Workbook(); + + // 데이터 타입 확인 (서비스에서 반환하는 실제 데이터 형태) + const vendorData = vendorsWithDetails as unknown as any[]; + + // ===== 1. 기본 정보 시트 ===== + createBasicInfoSheet(workbook, vendorData); + + // ===== 2. 연락처 시트 ===== + createContactsSheet(workbook, vendorData); + + // ===== 3. 아이템 시트 ===== + createItemsSheet(workbook, vendorData); + + // ===== 4. RFQ 시트 ===== + createRFQsSheet(workbook, vendorData); + + // ===== 5. 계약 시트 ===== + createContractsSheet(workbook, vendorData); + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer(); + const blob = new Blob([buffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${filename}-${new Date().toISOString().split("T")[0]}.xlsx`; + link.click(); + URL.revokeObjectURL(url); + + return; + } catch (error) { + console.error("Export error:", error); + throw error; + } +} + +// 기본 정보 시트 생성 함수 +function createBasicInfoSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const basicInfoSheet = workbook.addWorksheet("기본정보"); + + // 기본 정보 시트 헤더 설정 + basicInfoSheet.columns = [ + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + { header: "국가", key: "country", width: 10 }, + { header: "상태", key: "status", width: 15 }, + { header: "이메일", key: "email", width: 20 }, + { header: "전화번호", key: "phone", width: 15 }, + { header: "웹사이트", key: "website", width: 20 }, + { header: "주소", key: "address", width: 30 }, + { header: "대표자명", key: "representativeName", width: 15 }, + { header: "신용등급", key: "creditRating", width: 10 }, + { header: "현금흐름등급", key: "cashFlowRating", width: 10 }, + { header: "생성일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(basicInfoSheet); + + // 벤더 데이터 추가 + vendors.forEach((vendor: VendorData) => { + basicInfoSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + country: vendor.country, + status: getStatusText(vendor.status), // 상태 코드를 읽기 쉬운 텍스트로 변환 + email: vendor.email, + phone: vendor.phone, + website: vendor.website, + address: vendor.address, + representativeName: vendor.representativeName, + creditRating: vendor.creditRating, + cashFlowRating: vendor.cashFlowRating, + createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "", + }); + }); +} + +// 연락처 시트 생성 함수 +function createContactsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const contactsSheet = workbook.addWorksheet("연락처"); + + contactsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 연락처 정보 + { header: "이름", key: "contactName", width: 15 }, + { header: "직책", key: "contactPosition", width: 15 }, + { header: "이메일", key: "contactEmail", width: 25 }, + { header: "전화번호", key: "contactPhone", width: 15 }, + { header: "주요 연락처", key: "isPrimary", width: 10 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(contactsSheet); + + // 벤더별 연락처 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorContacts && vendor.vendorContacts.length > 0) { + vendor.vendorContacts.forEach((contact: VendorContact) => { + contactsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 연락처 정보 + contactName: contact.contactName, + contactPosition: contact.contactPosition || "", + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone || "", + isPrimary: contact.isPrimary ? "예" : "아니오", + }); + }); + } else { + // 연락처가 없는 경우에도 벤더 정보만 추가 + contactsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + contactName: "", + contactPosition: "", + contactEmail: "", + contactPhone: "", + isPrimary: "", + }); + } + }); +} + +// 아이템 시트 생성 함수 +function createItemsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const itemsSheet = workbook.addWorksheet("아이템"); + + itemsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 아이템 정보 + { header: "아이템 코드", key: "itemCode", width: 15 }, + { header: "아이템명", key: "itemName", width: 25 }, + { header: "설명", key: "description", width: 30 }, + { header: "등록일", key: "createdAt", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(itemsSheet); + + // 벤더별 아이템 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorItems && vendor.vendorItems.length > 0) { + vendor.vendorItems.forEach((item: VendorItem) => { + itemsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 아이템 정보 + itemCode: item.itemCode, + itemName: item.itemName, + description: item.description || "", + createdAt: item.createdAt ? formatDate(item.createdAt) : "", + }); + }); + } else { + // 아이템이 없는 경우에도 벤더 정보만 추가 + itemsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + itemCode: "", + itemName: "", + description: "", + createdAt: "", + }); + } + }); +} + +// RFQ 시트 생성 함수 +function createRFQsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const rfqsSheet = workbook.addWorksheet("RFQ"); + + rfqsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // RFQ 정보 + { header: "RFQ 번호", key: "rfqNumber", width: 15 }, + { header: "제목", key: "title", width: 25 }, + { header: "상태", key: "status", width: 15 }, + { header: "요청일", key: "requestDate", width: 15 }, + { header: "마감일", key: "dueDate", width: 15 }, + { header: "설명", key: "description", width: 30 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(rfqsSheet); + + // 벤더별 RFQ 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorRfqs && vendor.vendorRfqs.length > 0) { + vendor.vendorRfqs.forEach((rfq: VendorRFQ) => { + rfqsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // RFQ 정보 + rfqNumber: rfq.rfqNumber, + title: rfq.title, + status: rfq.status, + requestDate: rfq.requestDate ? formatDate(rfq.requestDate) : "", + dueDate: rfq.dueDate ? formatDate(rfq.dueDate) : "", + description: rfq.description || "", + }); + }); + } else { + // RFQ가 없는 경우에도 벤더 정보만 추가 + rfqsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + rfqNumber: "", + title: "", + status: "", + requestDate: "", + dueDate: "", + description: "", + }); + } + }); +} + +// 계약 시트 생성 함수 +function createContractsSheet( + workbook: ExcelJS.Workbook, + vendors: VendorData[] +): void { + const contractsSheet = workbook.addWorksheet("계약"); + + contractsSheet.columns = [ + // 벤더 식별 정보 + { header: "업체코드", key: "vendorCode", width: 15 }, + { header: "업체명", key: "vendorName", width: 20 }, + { header: "세금ID", key: "taxId", width: 15 }, + // 계약 정보 + { header: "프로젝트 코드", key: "projectCode", width: 15 }, + { header: "프로젝트명", key: "projectName", width: 20 }, + { header: "계약 번호", key: "contractNo", width: 15 }, + { header: "계약명", key: "contractName", width: 25 }, + { header: "상태", key: "status", width: 15 }, + { header: "지급 조건", key: "paymentTerms", width: 15 }, + { header: "납품 조건", key: "deliveryTerms", width: 15 }, + { header: "납품 일자", key: "deliveryDate", width: 15 }, + { header: "납품 위치", key: "deliveryLocation", width: 20 }, + { header: "계약 시작일", key: "startDate", width: 15 }, + { header: "계약 종료일", key: "endDate", width: 15 }, + { header: "통화", key: "currency", width: 10 }, + { header: "총액", key: "totalAmount", width: 15 }, + ]; + + // 헤더 스타일 설정 + applyHeaderStyle(contractsSheet); + + // 벤더별 계약 데이터 추가 + vendors.forEach((vendor: VendorData) => { + if (vendor.vendorContracts && vendor.vendorContracts.length > 0) { + vendor.vendorContracts.forEach((contract: VendorContract) => { + contractsSheet.addRow({ + // 벤더 식별 정보 + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + // 계약 정보 + projectCode: contract.projectCode, + projectName: contract.projectName, + contractNo: contract.contractNo, + contractName: contract.contractName, + status: contract.status, + paymentTerms: contract.paymentTerms, + deliveryTerms: contract.deliveryTerms, + deliveryDate: contract.deliveryDate ? formatDate(contract.deliveryDate) : "", + deliveryLocation: contract.deliveryLocation, + startDate: contract.startDate ? formatDate(contract.startDate) : "", + endDate: contract.endDate ? formatDate(contract.endDate) : "", + currency: contract.currency, + totalAmount: contract.totalAmount ? formatAmount(contract.totalAmount) : "", + }); + }); + } else { + // 계약이 없는 경우에도 벤더 정보만 추가 + contractsSheet.addRow({ + vendorCode: vendor.vendorCode || "", + vendorName: vendor.vendorName, + taxId: vendor.taxId, + projectCode: "", + projectName: "", + contractNo: "", + contractName: "", + status: "", + paymentTerms: "", + deliveryTerms: "", + deliveryDate: "", + deliveryLocation: "", + startDate: "", + endDate: "", + currency: "", + totalAmount: "", + }); + } + }); +} + +// 헤더 스타일 적용 함수 +function applyHeaderStyle(sheet: ExcelJS.Worksheet): void { + const headerRow = sheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { horizontal: "center" }; + headerRow.eachCell((cell: ExcelJS.Cell) => { + cell.fill = { + type: "pattern", + pattern: "solid", + fgColor: { argb: "FFCCCCCC" }, + }; + }); +} + +// 날짜 포맷 함수 +function formatDate(date: Date | string): string { + if (!date) return ""; + if (typeof date === 'string') { + date = new Date(date); + } + return date.toISOString().split('T')[0]; +} + +// 금액 포맷 함수 +function formatAmount(amount: number): string { + return amount.toLocaleString(); +} + +// 상태 코드를 읽기 쉬운 텍스트로 변환하는 함수 +function getStatusText(status: string): string { + const statusMap: Record<string, string> = { + "PENDING_REVIEW": "검토 대기중", + "IN_REVIEW": "검토 중", + "REJECTED": "거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출됨", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 승인됨", + "APPROVED": "승인됨", + "READY_TO_SEND": "발송 준비됨", + "ACTIVE": "활성", + "INACTIVE": "비활성", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; +}
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx index 77750c47..c768b587 100644 --- a/lib/vendors/table/vendors-table-columns.tsx +++ b/lib/vendors/table/vendors-table-columns.tsx @@ -27,30 +27,41 @@ import { import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" import { useRouter } from "next/navigation" -import { Vendor, vendors, VendorWithAttachments } from "@/db/schema/vendors" +import { VendorWithType, vendors, VendorWithAttachments } from "@/db/schema/vendors" import { modifyVendor } from "../service" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { vendorColumnsConfig } from "@/config/vendorColumnsConfig" import { Separator } from "@/components/ui/separator" import { AttachmentsButton } from "./attachmentButton" +import { getVendorStatusIcon } from "../utils" +// 타입 정의 추가 +type StatusType = (typeof vendors.status.enumValues)[number]; +type BadgeVariantType = "default" | "secondary" | "destructive" | "outline"; +type StatusConfig = { + variant: BadgeVariantType; + className: string; +}; +type StatusDisplayMap = { + [key in StatusType]: string; +}; type NextRouter = ReturnType<typeof useRouter>; - interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Vendor> | null>>; + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorWithType> | null>>; router: NextRouter; + userId: number; } /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<Vendor>[] { +export function getColumns({ setRowAction, router, userId }: GetColumnsProps): ColumnDef<VendorWithType>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- - const selectColumn: ColumnDef<Vendor> = { + const selectColumn: ColumnDef<VendorWithType> = { id: "select", header: ({ table }) => ( <Checkbox @@ -79,102 +90,103 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef // ---------------------------------------------------------------- // 2) actions 컬럼 (Dropdown 메뉴) // ---------------------------------------------------------------- -// ---------------------------------------------------------------- -// 2) actions 컬럼 (Dropdown 메뉴) -// ---------------------------------------------------------------- -const actionsColumn: ColumnDef<Vendor> = { - id: "actions", - enableHiding: false, - cell: function Cell({ row }) { - const [isUpdatePending, startUpdateTransition] = React.useTransition() - const isApproved = row.original.status === "APPROVED"; - - return ( - <DropdownMenu> - <DropdownMenuTrigger asChild> - <Button - aria-label="Open menu" - variant="ghost" - className="flex size-8 p-0 data-[state=open]:bg-muted" - > - <Ellipsis className="size-4" aria-hidden="true" /> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="w-56"> - <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "update" })} - > - Edit - </DropdownMenuItem> - <DropdownMenuItem - onSelect={() => { - // 1) 만약 rowAction을 열고 싶다면 - // setRowAction({ row, type: "update" }) - - // 2) 자세히 보기 페이지로 클라이언트 라우팅 - router.push(`/evcp/vendors/${row.original.id}/info`); - }} - > - Details - </DropdownMenuItem> - - {/* APPROVED 상태일 때만 추가 정보 기입 메뉴 표시 */} - {isApproved && ( + const actionsColumn: ColumnDef<VendorWithType> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const isApproved = row.original.status === "PQ_APPROVED"; + const afterApproved = row.original.status === "ACTIVE"; + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-56"> + {(isApproved ||afterApproved) && ( <DropdownMenuItem - onSelect={() => setRowAction({ row, type: "requestInfo" })} - className="text-blue-600 font-medium" + onSelect={() => setRowAction({ row, type: "update" })} > - 추가 정보 기입 + 레코드 편집 </DropdownMenuItem> - )} - - <Separator /> - <DropdownMenuSub> - <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> - <DropdownMenuSubContent> - <DropdownMenuRadioGroup - value={row.original.status} - onValueChange={(value) => { - startUpdateTransition(() => { - toast.promise( - modifyVendor({ - id: String(row.original.id), - status: value as Vendor["status"], - }), - { - loading: "Updating...", - success: "Label updated", - error: (err) => getErrorMessage(err), - } - ) - }) - }} - > - {vendors.status.enumValues.map((status) => ( - <DropdownMenuRadioItem - key={status} - value={status} - className="capitalize" - disabled={isUpdatePending} - > - {status} - </DropdownMenuRadioItem> - ))} - </DropdownMenuRadioGroup> - </DropdownMenuSubContent> - </DropdownMenuSub> - </DropdownMenuContent> - </DropdownMenu> - ) - }, - size: 40, -} + )} + + <DropdownMenuItem + onSelect={() => { + // 1) 만약 rowAction을 열고 싶다면 + // setRowAction({ row, type: "update" }) + + // 2) 자세히 보기 페이지로 클라이언트 라우팅 + router.push(`/evcp/vendors/${row.original.id}/info`); + }} + > + 상세보기 + </DropdownMenuItem> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "log" })} + > + 감사 로그 보기 + </DropdownMenuItem> + + <Separator /> + <DropdownMenuSub> + <DropdownMenuSubTrigger>Status</DropdownMenuSubTrigger> + <DropdownMenuSubContent> + <DropdownMenuRadioGroup + value={row.original.status} + onValueChange={(value) => { + startUpdateTransition(() => { + toast.promise( + modifyVendor({ + id: String(row.original.id), + status: value as VendorWithType["status"], + userId, + vendorName: row.original.vendorName, // Required field from UpdateVendorSchema + comment: `Status changed to ${value}` + }), + { + loading: "Updating...", + success: "Label updated", + error: (err) => getErrorMessage(err), + } + ) + }) + }} + > + {vendors.status.enumValues.map((status) => ( + <DropdownMenuRadioItem + key={status} + value={status} + className="capitalize" + disabled={isUpdatePending} + > + {status} + </DropdownMenuRadioItem> + ))} + </DropdownMenuRadioGroup> + </DropdownMenuSubContent> + </DropdownMenuSub> + + + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } // ---------------------------------------------------------------- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 // ---------------------------------------------------------------- - // 3-1) groupMap: { [groupName]: ColumnDef<Vendor>[] } - const groupMap: Record<string, ColumnDef<Vendor>[]> = {} + // 3-1) groupMap: { [groupName]: ColumnDef<VendorWithType>[] } + const groupMap: Record<string, ColumnDef<VendorWithType>[]> = {} vendorColumnsConfig.forEach((cfg) => { // 만약 group가 없으면 "_noGroup" 처리 @@ -185,7 +197,7 @@ const actionsColumn: ColumnDef<Vendor> = { } // child column 정의 - const childCol: ColumnDef<Vendor> = { + const childCol: ColumnDef<VendorWithType> = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( @@ -197,20 +209,158 @@ const actionsColumn: ColumnDef<Vendor> = { type: cfg.type, }, cell: ({ row, cell }) => { + // Status 컬럼 렌더링 개선 - 아이콘과 더 선명한 배경색 사용 + if (cfg.id === "status") { + const statusVal = row.original.status as StatusType; + if (!statusVal) return null; + // Status badge variant mapping - 더 뚜렷한 색상으로 변경 + const getStatusConfig = (status: StatusType): StatusConfig & { iconColor: string } => { + switch (status) { + case "PENDING_REVIEW": + return { + variant: "outline", + className: "bg-yellow-100 text-yellow-800 border-yellow-300", + iconColor: "text-yellow-600" + }; + case "IN_REVIEW": + return { + variant: "outline", + className: "bg-blue-100 text-blue-800 border-blue-300", + iconColor: "text-blue-600" + }; + case "REJECTED": + return { + variant: "outline", + className: "bg-red-100 text-red-800 border-red-300", + iconColor: "text-red-600" + }; + case "IN_PQ": + return { + variant: "outline", + className: "bg-purple-100 text-purple-800 border-purple-300", + iconColor: "text-purple-600" + }; + case "PQ_SUBMITTED": + return { + variant: "outline", + className: "bg-indigo-100 text-indigo-800 border-indigo-300", + iconColor: "text-indigo-600" + }; + case "PQ_FAILED": + return { + variant: "outline", + className: "bg-red-100 text-red-800 border-red-300", + iconColor: "text-red-600" + }; + case "PQ_APPROVED": + return { + variant: "outline", + className: "bg-green-100 text-green-800 border-green-300", + iconColor: "text-green-600" + }; + case "APPROVED": + return { + variant: "outline", + className: "bg-green-100 text-green-800 border-green-300", + iconColor: "text-green-600" + }; + case "READY_TO_SEND": + return { + variant: "outline", + className: "bg-emerald-100 text-emerald-800 border-emerald-300", + iconColor: "text-emerald-600" + }; + case "ACTIVE": + return { + variant: "outline", + className: "bg-emerald-100 text-emerald-800 border-emerald-300 font-semibold", + iconColor: "text-emerald-600" + }; + case "INACTIVE": + return { + variant: "outline", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; + case "BLACKLISTED": + return { + variant: "outline", + className: "bg-slate-800 text-white border-slate-900", + iconColor: "text-white" + }; + default: + return { + variant: "outline", + className: "bg-gray-100 text-gray-800 border-gray-300", + iconColor: "text-gray-600" + }; + } + }; + + // Translate status for display + const getStatusDisplay = (status: StatusType): string => { + const statusMap: StatusDisplayMap = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 통과", + "APPROVED": "승인됨", + "READY_TO_SEND": "MDG 송부대기", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const config = getStatusConfig(statusVal); + const displayText = getStatusDisplay(statusVal); + const StatusIcon = getVendorStatusIcon(statusVal); - if (cfg.id === "status") { - const statusVal = row.original.status - if (!statusVal) return null - // const Icon = getStatusIcon(statusVal) return ( - <div className="flex w-[6.25rem] items-center"> - {/* <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" /> */} - <span className="capitalize">{statusVal}</span> - </div> - ) + <Badge variant={config.variant} className={`flex items-center px-2 py-1 ${config.className}`}> + <StatusIcon className={`mr-1 h-3.5 w-3.5 ${config.iconColor}`} /> + <span>{displayText}</span> + </Badge> + ); } + // 업체 유형 컬럼 처리 + if (cfg.id === "vendorTypeName") { + const typeVal = row.original.vendorTypeName as string | null; + return typeVal ? ( + <span className="text-sm font-medium"> + {typeVal} + </span> + ) : ( + <span className="text-sm text-gray-400">미지정</span> + ); + } + + // 업체 분류 컬럼 처리 (별도로 표시하고 싶은 경우) + if (cfg.id === "vendorCategory") { + const categoryVal = row.original.vendorCategory as string | null; + if (!categoryVal) return null; + + let badgeClass = ""; + + if (categoryVal === "정규업체") { + badgeClass = "bg-green-50 text-green-700 border-green-200"; + } else if (categoryVal === "잠재업체") { + badgeClass = "bg-blue-50 text-blue-700 border-blue-200"; + } + + return ( + <Badge variant="outline" className={badgeClass}> + {categoryVal} + </Badge> + ); + } if (cfg.id === "createdAt") { const dateVal = cell.getValue() as Date @@ -222,10 +372,10 @@ const actionsColumn: ColumnDef<Vendor> = { return formatDate(dateVal) } - // code etc... return row.getValue(cfg.id) ?? "" }, + minSize: 150 } groupMap[groupName].push(childCol) @@ -234,7 +384,7 @@ const actionsColumn: ColumnDef<Vendor> = { // ---------------------------------------------------------------- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<Vendor>[] = [] + const nestedColumns: ColumnDef<VendorWithType>[] = [] // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 // 여기서는 그냥 Object.entries 순서 @@ -252,34 +402,35 @@ const actionsColumn: ColumnDef<Vendor> = { } }) - const attachmentsColumn: ColumnDef<VendorWithAttachments> = { + // attachments 컬럼 타입 문제 해결을 위한 타입 단언 + const attachmentsColumn: ColumnDef<VendorWithType> = { id: "attachments", header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="" /> ), cell: ({ row }) => { // hasAttachments 및 attachmentsList 속성이 추가되었다고 가정 - const hasAttachments = row.original.hasAttachments; - const attachmentsList = row.original.attachmentsList || []; - - if(hasAttachments){ + const hasAttachments = (row.original as VendorWithAttachments).hasAttachments; + const attachmentsList = (row.original as VendorWithAttachments).attachmentsList || []; - // 서버 액션을 사용하는 컴포넌트로 교체 - return ( - <AttachmentsButton - vendorId={row.original.id} - hasAttachments={hasAttachments} - attachmentsList={attachmentsList} - /> - );}{ - return null + if (hasAttachments) { + // 서버 액션을 사용하는 컴포넌트로 교체 + return ( + <AttachmentsButton + vendorId={row.original.id} + hasAttachments={hasAttachments} + attachmentsList={attachmentsList} + /> + ); + } else { + return null; } }, enableSorting: false, enableHiding: false, minSize: 45, }; - + // ---------------------------------------------------------------- // 4) 최종 컬럼 배열: select, nestedColumns, actions diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx index 3cb2c552..1c788911 100644 --- a/lib/vendors/table/vendors-table-toolbar-actions.tsx +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -2,7 +2,7 @@ import * as React from "react" import { type Table } from "@tanstack/react-table" -import { Download, Upload, Check, BuildingIcon } from "lucide-react" +import { Download, FileSpreadsheet, Upload, Check, BuildingIcon, FileText } from "lucide-react" import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" @@ -11,25 +11,29 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { Vendor } from "@/db/schema/vendors" +import { VendorWithType } from "@/db/schema/vendors" import { ApproveVendorsDialog } from "./approve-vendor-dialog" import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog" import { RequestProjectPQDialog } from "./request-project-pq-dialog" import { SendVendorsDialog } from "./send-vendor-dialog" import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog" import { RequestInfoDialog } from "./request-additional-Info-dialog" +import { RequestContractDialog } from "./request-basicContract-dialog" +import { exportVendorsWithRelatedData } from "./vendor-all-export" interface VendorsTableToolbarActionsProps { - table: Table<Vendor> + table: Table<VendorWithType> } export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { + const [isExporting, setIsExporting] = React.useState(false); // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식 const fileInputRef = React.useRef<HTMLInputElement>(null) - // 선택된 벤더 중 PENDING_REVIEW 상태인 벤더만 필터링 + // 선택된 협력업체 중 PENDING_REVIEW 상태인 협력업체만 필터링 const pendingReviewVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -38,7 +42,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .filter(vendor => vendor.status === "PENDING_REVIEW"); }, [table.getFilteredSelectedRowModel().rows]); - // 선택된 벤더 중 IN_REVIEW 상태인 벤더만 필터링 + // 선택된 협력업체 중 IN_REVIEW 상태인 협력업체만 필터링 const inReviewVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -71,7 +75,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .filter(vendor => vendor.status === "PQ_APPROVED"); }, [table.getFilteredSelectedRowModel().rows]); - // 프로젝트 PQ를 보낼 수 있는 벤더 상태 필터링 + // 프로젝트 PQ를 보낼 수 있는 협력업체 상태 필터링 const projectPQEligibleVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() @@ -81,10 +85,66 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions ["PENDING_REVIEW", "IN_REVIEW", "IN_PQ", "PQ_APPROVED", "APPROVED", "READY_TO_SEND", "ACTIVE"].includes(vendor.status) ); }, [table.getFilteredSelectedRowModel().rows]); + + // 선택된 모든 벤더 가져오기 + const selectedVendors = React.useMemo(() => { + return table + .getFilteredSelectedRowModel() + .rows + .map(row => row.original); + }, [table.getFilteredSelectedRowModel().rows]); + + // 테이블의 모든 벤더 가져오기 (필터링된 결과) + const allFilteredVendors = React.useMemo(() => { + return table + .getFilteredRowModel() + .rows + .map(row => row.original); + }, [table.getFilteredRowModel().rows]); + + // 선택된 벤더 통합 내보내기 함수 실행 + const handleSelectedExport = async () => { + if (selectedVendors.length === 0) { + toast.warning("내보낼 협력업체를 선택해주세요."); + return; + } + + try { + setIsExporting(true); + toast.info(`선택된 ${selectedVendors.length}개 업체의 정보를 내보내는 중입니다...`); + await exportVendorsWithRelatedData(selectedVendors, "selected-vendors-detailed"); + toast.success(`${selectedVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); + } catch (error) { + console.error("상세 정보 내보내기 오류:", error); + toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); + } finally { + setIsExporting(false); + } + }; + + // 모든 벤더 통합 내보내기 함수 실행 + const handleAllFilteredExport = async () => { + if (allFilteredVendors.length === 0) { + toast.warning("내보낼 협력업체가 없습니다."); + return; + } + + try { + setIsExporting(true); + toast.info(`총 ${allFilteredVendors.length}개 업체의 정보를 내보내는 중입니다...`); + await exportVendorsWithRelatedData(allFilteredVendors, "all-vendors-detailed"); + toast.success(`${allFilteredVendors.length}개 업체 정보 내보내기가 완료되었습니다.`); + } catch (error) { + console.error("상세 정보 내보내기 오류:", error); + toast.error("상세 정보 내보내기 중 오류가 발생했습니다."); + } finally { + setIsExporting(false); + } + }; return ( <div className="flex items-center gap-2"> - {/* 승인 다이얼로그: PENDING_REVIEW 상태인 벤더가 있을 때만 표시 */} + {/* 승인 다이얼로그: PENDING_REVIEW 상태인 협력업체가 있을 때만 표시 */} {pendingReviewVendors.length > 0 && ( <ApproveVendorsDialog vendors={pendingReviewVendors} @@ -92,7 +152,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/* 일반 PQ 요청: IN_REVIEW 상태인 벤더가 있을 때만 표시 */} + {/* 일반 PQ 요청: IN_REVIEW 상태인 협력업체가 있을 때만 표시 */} {inReviewVendors.length > 0 && ( <RequestPQVendorsDialog vendors={inReviewVendors} @@ -100,7 +160,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/* 프로젝트 PQ 요청: 적격 상태의 벤더가 있을 때만 표시 */} + {/* 프로젝트 PQ 요청: 적격 상태의 협력업체가 있을 때만 표시 */} {projectPQEligibleVendors.length > 0 && ( <RequestProjectPQDialog vendors={projectPQEligibleVendors} @@ -109,13 +169,13 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions )} {approvedVendors.length > 0 && ( - <RequestInfoDialog + <RequestContractDialog vendors={approvedVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> )} - {sendVendors.length > 0 && ( + {pqApprovedVendors.length > 0 && ( <RequestInfoDialog vendors={sendVendors} onSuccess={() => table.toggleAllRowsSelected(false)} @@ -129,21 +189,63 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {/** 4) Export 버튼 */} - <Button - variant="outline" - size="sm" - onClick={() => - exportTableToExcel(table, { - filename: "vendors", - excludeColumns: ["select", "actions"], - }) - } - className="gap-2" - > - <Download className="size-4" aria-hidden="true" /> - <span className="hidden sm:inline">Export</span> - </Button> + {/* Export 드롭다운 메뉴로 변경 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="outline" + size="sm" + className="gap-2" + disabled={isExporting} + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline"> + {isExporting ? "내보내는 중..." : "Export"} + </span> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {/* 기본 내보내기 - 현재 테이블에 보이는 데이터만 */} + <DropdownMenuItem + onClick={() => + exportTableToExcel(table, { + filename: "vendors", + excludeColumns: ["select", "actions"], + }) + } + disabled={isExporting} + > + <FileText className="mr-2 size-4" /> + <span>현재 테이블 데이터 내보내기</span> + </DropdownMenuItem> + + <DropdownMenuSeparator /> + + {/* 선택된 벤더만 상세 내보내기 */} + <DropdownMenuItem + onClick={handleSelectedExport} + disabled={selectedVendors.length === 0 || isExporting} + > + <FileSpreadsheet className="mr-2 size-4" /> + <span>선택한 업체 상세 정보 내보내기</span> + {selectedVendors.length > 0 && ( + <span className="ml-1 text-xs text-muted-foreground">({selectedVendors.length}개)</span> + )} + </DropdownMenuItem> + + {/* 모든 필터링된 벤더 상세 내보내기 */} + <DropdownMenuItem + onClick={handleAllFilteredExport} + disabled={allFilteredVendors.length === 0 || isExporting} + > + <Download className="mr-2 size-4" /> + <span>모든 업체 상세 정보 내보내기</span> + {allFilteredVendors.length > 0 && ( + <span className="ml-1 text-xs text-muted-foreground">({allFilteredVendors.length}개)</span> + )} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> </div> ) }
\ No newline at end of file diff --git a/lib/vendors/table/vendors-table.tsx b/lib/vendors/table/vendors-table.tsx index 36fd45bd..02768f32 100644 --- a/lib/vendors/table/vendors-table.tsx +++ b/lib/vendors/table/vendors-table.tsx @@ -8,19 +8,18 @@ import type { DataTableRowAction, } from "@/types/table" -import { toSentenceCase } from "@/lib/utils" 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 { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./vendors-table-columns" import { getVendors, getVendorStatusCounts } from "../service" -import { Vendor, vendors } from "@/db/schema/vendors" +import { VendorWithType, vendors } from "@/db/schema/vendors" import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" -import { VendorsTableFloatingBar } from "./vendors-table-floating-bar" -import { UpdateTaskSheet } from "@/lib/tasks/table/update-task-sheet" import { UpdateVendorSheet } from "./update-vendor-sheet" import { getVendorStatusIcon } from "@/lib/vendors/utils" +import { ViewVendorLogsDialog } from "./view-vendors_logs-dialog" +import { useSession } from "next-auth/react" interface VendorsTableProps { promises: Promise< @@ -32,58 +31,83 @@ interface VendorsTableProps { } export function VendorsTable({ promises }: VendorsTableProps) { - const { featureFlags } = useFeatureFlags() - + const { data: session } = useSession() + const userId = Number(session?.user.id) + // Suspense로 받아온 데이터 const [{ data, pageCount }, statusCounts] = React.use(promises) + const [isCompact, setIsCompact] = React.useState<boolean>(false) - const [rowAction, setRowAction] = React.useState<DataTableRowAction<Vendor> | null>(null) - + const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorWithType> | null>(null) + // **router** 획득 const router = useRouter() - + // getColumns() 호출 시, router를 주입 const columns = React.useMemo( - () => getColumns({ setRowAction, router }), - [setRowAction, router] + () => getColumns({ setRowAction, router , userId}), + [setRowAction, router, userId] ) - - const filterFields: DataTableFilterField<Vendor>[] = [ + + // 상태 한글 변환 유틸리티 함수 + const getStatusDisplay = (status: string): string => { + const statusMap: Record<string, string> = { + "PENDING_REVIEW": "가입 신청 중", + "IN_REVIEW": "심사 중", + "REJECTED": "심사 거부됨", + "IN_PQ": "PQ 진행 중", + "PQ_SUBMITTED": "PQ 제출", + "PQ_FAILED": "PQ 실패", + "PQ_APPROVED": "PQ 통과", + "APPROVED": "승인됨", + "READY_TO_SEND": "MDG 송부대기", + "ACTIVE": "활성 상태", + "INACTIVE": "비활성 상태", + "BLACKLISTED": "거래 금지" + }; + + return statusMap[status] || status; + }; + + const filterFields: DataTableFilterField<VendorWithType>[] = [ { id: "status", - label: "Status", + label: "상태", options: vendors.status.enumValues.map((status) => ({ - label: toSentenceCase(status), + label: getStatusDisplay(status), value: status, count: statusCounts[status], })), }, - - { id: "vendorCode", label: "Vendor Code" }, - + + { id: "vendorCode", label: "업체 코드" }, ] - - const advancedFilterFields: DataTableAdvancedFilterField<Vendor>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, - { id: "email", label: "Email", type: "text" }, - { id: "country", label: "Country", type: "text" }, + + const advancedFilterFields: DataTableAdvancedFilterField<VendorWithType>[] = [ + { id: "vendorName", label: "업체명", type: "text" }, + { id: "vendorCode", label: "업체코드", type: "text" }, + { id: "email", label: "이메일", type: "text" }, + { id: "country", label: "국가", type: "text" }, + { id: "vendorTypeName", label: "업체 유형", type: "text" }, + { id: "vendorCategory", label: "업체 분류", type: "select", options: [ + { label: "정규업체", value: "정규업체" }, + { label: "잠재업체", value: "잠재업체" }, + ]}, { id: "status", - label: "Status", + label: "업체승인상태", type: "multi-select", options: vendors.status.enumValues.map((status) => ({ - label: (status), + label: getStatusDisplay(status), value: status, count: statusCounts[status], icon: getVendorStatusIcon(status), - })), }, - { id: "createdAt", label: "Created at", type: "date" }, - { id: "updatedAt", label: "Updated at", type: "date" }, + { id: "createdAt", label: "등록일", type: "date" }, + { id: "updatedAt", label: "수정일", type: "date" }, ] - + const { table } = useDataTable({ data, columns, @@ -100,16 +124,25 @@ export function VendorsTable({ promises }: VendorsTableProps) { clearOnDefault: true, }) + const handleCompactChange = React.useCallback((compact: boolean) => { + setIsCompact(compact) + }, []) + + return ( <> <DataTable table={table} + compact={isCompact} // floatingBar={<VendorsTableFloatingBar table={table} />} > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} shallow={false} + enableCompactToggle={true} + compactStorageKey="vendorsTableCompact" + onCompactChange={handleCompactChange} > <VendorsTableToolbarActions table={table} /> </DataTableAdvancedToolbar> @@ -119,6 +152,12 @@ export function VendorsTable({ promises }: VendorsTableProps) { onOpenChange={() => setRowAction(null)} vendor={rowAction?.row.original ?? null} /> + + <ViewVendorLogsDialog + open={rowAction?.type === "log"} + onOpenChange={() => setRowAction(null)} + vendorId={rowAction?.row.original?.id ?? null} + /> </> ) }
\ No newline at end of file diff --git a/lib/vendors/table/view-vendors_logs-dialog.tsx b/lib/vendors/table/view-vendors_logs-dialog.tsx new file mode 100644 index 00000000..7402ae55 --- /dev/null +++ b/lib/vendors/table/view-vendors_logs-dialog.tsx @@ -0,0 +1,244 @@ +"use client" + +import * as React from "react" +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { formatDateTime } from "@/lib/utils" +import { useToast } from "@/hooks/use-toast" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Badge } from "@/components/ui/badge" +import { Download, Search, User } from "lucide-react" +import { VendorsLogWithUser, getVendorLogs } from "../service" + +interface VendorLogsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null +} + +export function ViewVendorLogsDialog({ + open, + onOpenChange, + vendorId, +}: VendorLogsDialogProps) { + const [logs, setLogs] = React.useState<VendorsLogWithUser[]>([]) + const [filteredLogs, setFilteredLogs] = React.useState<VendorsLogWithUser[]>([]) + const [loading, setLoading] = React.useState(false) + const [error, setError] = React.useState<string | null>(null) + const [searchQuery, setSearchQuery] = React.useState("") + const [actionFilter, setActionFilter] = React.useState<string>("all") + const { toast } = useToast() + + // Get unique action types for filter dropdown + const actionTypes = React.useMemo(() => { + if (!logs.length) return [] + return Array.from(new Set(logs.map(log => log.action))) + }, [logs]) + + // Fetch logs when dialog opens + React.useEffect(() => { + if (open && vendorId) { + setLoading(true) + setError(null) + getVendorLogs(vendorId) + .then((res) => { + setLogs(res) + setFilteredLogs(res) + }) + .catch((err) => { + console.error(err) + setError("Failed to load logs. Please try again.") + toast({ + variant: "destructive", + title: "Error", + description: "Failed to load candidate logs", + }) + }) + .finally(() => setLoading(false)) + } else { + // Reset state when dialog closes + setSearchQuery("") + setActionFilter("all") + } + }, [open, vendorId, toast]) + + // Filter logs based on search query and action filter + React.useEffect(() => { + if (!logs.length) return + + let result = [...logs] + + // Apply action filter + if (actionFilter !== "all") { + result = result.filter(log => log.action === actionFilter) + } + + // Apply search filter (case insensitive) + if (searchQuery) { + const query = searchQuery.toLowerCase() + result = result.filter(log => + log.action.toLowerCase().includes(query) || + (log.comment && log.comment.toLowerCase().includes(query)) || + (log.oldStatus && log.oldStatus.toLowerCase().includes(query)) || + (log.newStatus && log.newStatus.toLowerCase().includes(query)) || + (log.userName && log.userName.toLowerCase().includes(query)) || + (log.userEmail && log.userEmail.toLowerCase().includes(query)) + ) + } + + setFilteredLogs(result) + }, [logs, searchQuery, actionFilter]) + + // Export logs as CSV + const exportLogs = () => { + if (!filteredLogs.length) return + + const headers = ["Action", "Old Status", "New Status", "Comment", "User", "Email", "Date"] + const csvContent = [ + headers.join(","), + ...filteredLogs.map(log => [ + `"${log.action}"`, + `"${log.oldStatus || ''}"`, + `"${log.newStatus || ''}"`, + `"${log.comment?.replace(/"/g, '""') || ''}"`, + `"${log.userName || ''}"`, + `"${log.userEmail || ''}"`, + `"${formatDateTime(log.createdAt)}"` + ].join(",")) + ].join("\n") + + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.setAttribute("href", url) + link.setAttribute("download", `vendor-logs-${vendorId}-${new Date().toISOString().split('T')[0]}.csv`) + link.style.visibility = "hidden" + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + } + + // Render status change with appropriate badge + const renderStatusChange = (oldStatus: string, newStatus: string) => { + return ( + <div className="text-sm flex flex-wrap gap-2 items-center"> + <strong>Status:</strong> + <Badge variant="outline" className="text-xs">{oldStatus}</Badge> + <span>→</span> + <Badge variant="outline" className="bg-primary/10 text-xs">{newStatus}</Badge> + </div> + ) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <DialogTitle>Audit Logs</DialogTitle> + </DialogHeader> + + {/* Filters and search */} + <div className="flex items-center gap-2 mb-4"> + <div className="relative flex-1"> + <div className="absolute inset-y-0 left-0 flex items-center pl-2 pointer-events-none"> + <Search className="h-4 w-4 text-muted-foreground" /> + </div> + <Input + placeholder="Search logs..." + value={searchQuery} + onChange={(e) => setSearchQuery(e.target.value)} + className="pl-8" + /> + </div> + + <Select + value={actionFilter} + onValueChange={setActionFilter} + > + <SelectTrigger className="w-[180px]"> + <SelectValue placeholder="Filter by action" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="all">All actions</SelectItem> + {actionTypes.map(action => ( + <SelectItem key={action} value={action}>{action}</SelectItem> + ))} + </SelectContent> + </Select> + + <Button + size="icon" + variant="outline" + onClick={exportLogs} + disabled={filteredLogs.length === 0} + title="Export to CSV" + > + <Download className="h-4 w-4" /> + </Button> + </div> + + <div className="space-y-2"> + {loading && ( + <div className="flex justify-center py-8"> + <p className="text-muted-foreground">Loading logs...</p> + </div> + )} + + {error && !loading && ( + <div className="bg-destructive/10 text-destructive p-3 rounded-md"> + {error} + </div> + )} + + {!loading && !error && filteredLogs.length === 0 && ( + <p className="text-muted-foreground text-center py-8"> + {logs.length > 0 ? "No logs match your search criteria." : "No logs found for this candidate."} + </p> + )} + + {!loading && !error && filteredLogs.length > 0 && ( + <> + <div className="text-xs text-muted-foreground mb-2"> + Showing {filteredLogs.length} {filteredLogs.length === 1 ? 'log' : 'logs'} + {filteredLogs.length !== logs.length && ` (filtered from ${logs.length})`} + </div> + <div className="max-h-96 space-y-4 pr-4 overflow-y-auto"> + {filteredLogs.map((log) => ( + <div key={log.id} className="rounded-md border p-4 mb-3 hover:bg-muted/50 transition-colors"> + <div className="flex justify-between items-start mb-2"> + <Badge className="text-xs">{log.action}</Badge> + <div className="text-xs text-muted-foreground"> + {formatDateTime(log.createdAt)} + </div> + </div> + + {log.oldStatus && log.newStatus && ( + <div className="my-2"> + {renderStatusChange(log.oldStatus, log.newStatus)} + </div> + )} + + {log.comment && ( + <div className="my-2 text-sm bg-muted/50 p-2 rounded-md"> + <strong>Comment:</strong> {log.comment} + </div> + )} + + {(log.userName || log.userEmail) && ( + <div className="mt-3 pt-2 border-t flex items-center text-xs text-muted-foreground"> + <User className="h-3 w-3 mr-1" /> + {log.userName || "Unknown"} + {log.userEmail && <span className="ml-1">({log.userEmail})</span>} + </div> + )} + </div> + ))} + </div> + </> + )} + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendors/validations.ts b/lib/vendors/validations.ts index 1c08f8ff..e01fa8b9 100644 --- a/lib/vendors/validations.ts +++ b/lib/vendors/validations.ts @@ -8,7 +8,7 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors } from "@/db/schema/vendors"; +import { Vendor, VendorContact, vendorInvestigationsView, VendorItemsView, vendors, VendorWithType } from "@/db/schema/vendors"; import { rfqs } from "@/db/schema/rfq" @@ -24,7 +24,7 @@ export const searchParamsCache = createSearchParamsCache({ perPage: parseAsInteger.withDefault(10), // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) - sort: getSortingStateParser<Vendor>().withDefault([ + sort: getSortingStateParser<VendorWithType>().withDefault([ { id: "createdAt", desc: true }, // createdAt 기준 내림차순 ]), @@ -36,12 +36,12 @@ export const searchParamsCache = createSearchParamsCache({ search: parseAsString.withDefault(""), // ----------------------------------------------------------------- - // 여기부터는 "벤더"에 특화된 검색 필드 예시 + // 여기부터는 "협력업체"에 특화된 검색 필드 예시 // ----------------------------------------------------------------- // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택 status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED"]), - // 벤더명 검색 + // 협력업체명 검색 vendorName: parseAsString.withDefault(""), // 국가 검색 @@ -114,21 +114,32 @@ export const searchParamsItemCache = createSearchParamsCache({ description: parseAsString.withDefault(""), }); +const creditAgencyEnum = z.enum(["NICE", "KIS", "KED", "SCI"]); +export type CreditAgencyType = z.infer<typeof creditAgencyEnum>; + export const updateVendorSchema = z.object({ - vendorName: z.string().min(1, "Vendor name is required").max(255, "Max length 255").optional(), - vendorCode: z.string().max(100, "Max length 100").optional(), + vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"), + vendorCode: z.string().optional(), address: z.string().optional(), - country: z.string().max(100, "Max length 100").optional(), - phone: z.string().max(50, "Max length 50").optional(), - email: z.string().email("Invalid email").max(255).optional(), - website: z.string().url("Invalid URL").max(255).optional(), - - // status는 특정 값만 허용하도록 enum 사용 예시 - // 필요 시 'SUSPENDED', 'BLACKLISTED' 등 추가하거나 제거 가능 - status: z.enum(vendors.status.enumValues) - .optional() - .default("ACTIVE"), + country: z.string().optional(), + phone: z.string().optional(), + email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(), + website: z.string().url("유효한 URL을 입력해주세요").optional(), + status: z.enum(vendors.status.enumValues).optional(), + vendorTypeId: z.number().optional(), + + // Optional fields for buyer information + buyerName: z.string().optional(), + buyerDepartment: z.string().optional(), + contractStartDate: z.date().optional(), + contractEndDate: z.date().optional(), + internalNotes: z.string().optional(), + creditRating: z.string().optional(), + cashFlowRating: z.string().optional(), + creditAgency: creditAgencyEnum.optional(), + + // evaluationScore: z.string().optional(), }); @@ -151,9 +162,9 @@ export const createVendorSchema = z .string() .min(1, "Vendor name is required") .max(255, "Max length 255"), - email: z.string().email("Invalid email").max(255), - taxId: z.string().max(100, "Max length 100"), + vendorTypeId: z.number({ required_error: "업체유형을 선택해주세요" }), + email: z.string().email("Invalid email").max(255), // 나머지 optional vendorCode: z.string().max(100, "Max length 100").optional(), address: z.string().optional(), @@ -163,8 +174,6 @@ export const createVendorSchema = z phone: z.string().max(50, "Max length 50").optional(), website: z.string().url("Invalid URL").max(255).optional(), - creditRatingAttachment: z.any().optional(), // 신용평가 첨부 - cashFlowRatingAttachment: z.any().optional(), // 현금흐름 첨부 attachedFiles: z.any() .refine( val => { @@ -183,10 +192,9 @@ export const createVendorSchema = z representativeEmail: z.union([z.string().email("Invalid email").max(255), z.literal("")]).optional(), representativePhone: z.union([z.string().max(50), z.literal("")]).optional(), corporateRegistrationNumber: z.union([z.string().max(100), z.literal("")]).optional(), + taxId: z.string().min(1, { message: "사업자등록번호를 입력해주세요" }), - creditAgency: z.string().max(50).optional(), - creditRating: z.string().max(50).optional(), - cashFlowRating: z.string().max(50).optional(), + items: z.string().min(1, { message: "공급품목을 입력해주세요" }), contacts: z .array(contactSchema) @@ -233,28 +241,7 @@ export const createVendorSchema = z }) } - // 2) 신용/현금흐름 등급도 필수라면 - if (!data.creditAgency) { - ctx.addIssue({ - code: "custom", - path: ["creditAgency"], - message: "신용평가사 선택은 한국(KR) 업체일 경우 필수입니다.", - }) - } - if (!data.creditRating) { - ctx.addIssue({ - code: "custom", - path: ["creditRating"], - message: "신용평가등급은 한국(KR) 업체일 경우 필수입니다.", - }) - } - if (!data.cashFlowRating) { - ctx.addIssue({ - code: "custom", - path: ["cashFlowRating"], - message: "현금흐름등급은 한국(KR) 업체일 경우 필수입니다.", - }) - } + } } ) @@ -349,7 +336,7 @@ export const updateVendorInfoSchema = z.object({ phone: z.string().optional(), email: z.string().email("유효한 이메일을 입력해 주세요."), website: z.string().optional(), - + // 한국 사업자 정보 (KR일 경우 필수 항목들) representativeName: z.string().optional(), representativeBirth: z.string().optional(), |
