import { createSearchParamsCache, parseAsArrayOf, parseAsInteger, parseAsString, parseAsStringEnum, } from "nuqs/server" import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" import { Vendor, VendorContact, VendorItemsView, VendorMaterialsView, vendors, VendorWithType } from "@/db/schema/vendors"; import { rfqs } from "@/db/schema/rfq" export const searchParamsCache = createSearchParamsCache({ // 공통 플래그 flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( [] ), // 페이징 page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) sort: getSortingStateParser().withDefault([ { id: "createdAt", desc: true }, // createdAt 기준 내림차순 ]), // 고급 필터 filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), // 검색 키워드 search: parseAsString.withDefault(""), // ----------------------------------------------------------------- // 여기부터는 "협력업체"에 특화된 검색 필드 예시 // ----------------------------------------------------------------- // 상태 (ACTIVE, INACTIVE, BLACKLISTED 등) 중에서 선택 status: parseAsStringEnum(["ACTIVE", "INACTIVE", "BLACKLISTED"]), // 협력업체명 검색 vendorName: parseAsString.withDefault(""), // 국가 검색 country: parseAsString.withDefault(""), // 예) 코드 검색 vendorCode: parseAsString.withDefault(""), // 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능 email: parseAsString.withDefault(""), website: parseAsString.withDefault(""), }); export const searchParamsContactCache = createSearchParamsCache({ // 공통 플래그 flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( [] ), // 페이징 page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) sort: getSortingStateParser().withDefault([ { id: "createdAt", desc: true }, // createdAt 기준 내림차순 ]), // 고급 필터 filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), // 검색 키워드 search: parseAsString.withDefault(""), contactName: parseAsString.withDefault(""), contactPosition: parseAsString.withDefault(""), contactEmail: parseAsString.withDefault(""), contactPhone: parseAsString.withDefault(""), }); export const searchParamsItemCache = createSearchParamsCache({ // 공통 플래그 flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( [] ), // 페이징 page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) sort: getSortingStateParser().withDefault([ { id: "createdAt", desc: true }, // createdAt 기준 내림차순 ]), // 고급 필터 filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), // 검색 키워드 search: parseAsString.withDefault(""), itemName: parseAsString.withDefault(""), itemCode: parseAsString.withDefault(""), description: parseAsString.withDefault(""), }); const creditAgencyEnum = z.enum(["NICE", "KIS", "KED", "SCI"]); export type CreditAgencyType = z.infer; export const updateVendorSchema = z.object({ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다"), vendorCode: z.string().optional(), address: z.string().min(1, "주소는 필수 입력사항입니다."), addressDetail: z.string().optional(), postalCode: z.string().optional(), 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(), }); const contactSchema = z.object({ contactName: z .string() .min(1, "Contact name is required") .max(255, "Max length 255"), contactPosition: z.string().max(100).optional(), contactDepartment: z.string().max(100).optional(), contactTask: z.string().max(100).optional(), contactEmail: z.string().email("Invalid email").max(255), contactPhone: z.string().max(50).optional(), isPrimary: z.boolean().default(false).optional()}) const vendorStatusEnum = z.enum(vendors.status.enumValues) // CREATE 시: 일부 필드는 필수, 일부는 optional // Phone validation helper function const validatePhoneByCountry = (phone: string, country: string): boolean => { if (!phone || !country) return false; // Remove spaces, hyphens, and parentheses for validation const cleanPhone = phone.replace(/[\s\-\(\)]/g, ''); switch (country) { case 'KR': // South Korea // Should start with +82 and have 10-11 digits after country code return /^\+82[1-9]\d{7,9}$/.test(cleanPhone) || /^0[1-9]\d{7,9}$/.test(cleanPhone); case 'US': // United States case 'CA': // Canada // Should start with +1 and have exactly 10 digits after return /^\+1[2-9]\d{9}$/.test(cleanPhone); case 'JP': // Japan // Should start with +81 and have 10-11 digits after country code return /^\+81[1-9]\d{8,9}$/.test(cleanPhone); case 'CN': // China // Should start with +86 and have 11 digits after country code return /^\+86[1-9]\d{9}$/.test(cleanPhone); case 'GB': // United Kingdom // Should start with +44 and have 10-11 digits after country code return /^\+44[1-9]\d{8,9}$/.test(cleanPhone); case 'DE': // Germany // Should start with +49 and have 10-12 digits after country code return /^\+49[1-9]\d{9,11}$/.test(cleanPhone); case 'FR': // France // Should start with +33 and have 9 digits after country code return /^\+33[1-9]\d{8}$/.test(cleanPhone); default: // For other countries, just check if it starts with + and has reasonable length return /^\+\d{10,15}$/.test(cleanPhone); } }; // Enhanced createVendorSchema with phone validation export const createVendorSchema = z .object({ vendorName: z .string() .min(1, "Vendor name is required") .max(255, "Max length 255"), vendorTypeId: z.number({ required_error: "업체유형을 선택해주세요" }), representativeWorkExpirence: z.boolean(), email: z.string().email("Invalid email").max(255), // Updated phone validation - now required and must include country code phone: z.string() .min(1, "전화번호는 필수입니다") .max(50, "Max length 50"), // Other fields remain the same vendorCode: z.string().max(100, "Max length 100").optional(), address: z.string() .min(1, "주소는 필수입니다.") .max(500, "주소는 최대 500자까지 입력 가능합니다."), addressDetail: z.string() .min(1, "상세주소는 필수입니다.") .max(500, "상세주소는 최대 500자까지 입력 가능합니다."), postalCode: z.string() .min(1, "우편번호는 필수입니다.") .max(20, "우편번호는 최대 20자까지 입력 가능합니다."), country: z.string() .min(1, "국가 선택은 필수입니다.") .max(100, "Max length 100"), website: z.string().url("유효하지 않은 URL입니다. https:// 혹은 http:// 로 시작하는 주소를 입력해주세요.").max(255).optional(), // attachedFiles: z.any() // .refine( // val => { // return val && // (Array.isArray(val) ? val.length > 0 : // val instanceof FileList ? val.length > 0 : // val && typeof val === 'object' && 'length' in val && val.length > 0); // }, // { message: "첨부 파일은 필수입니다." } // ), status: vendorStatusEnum.default("PENDING_REVIEW"), representativeName: z.union([z.string().max(255), z.literal("")]).optional(), representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(), 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: "사업자등록번호를 입력해주세요" }), items: z.string().min(1, { message: "공급품목을 입력해주세요" }), contacts: z .array(contactSchema) .nonempty("At least one contact is required."), }) .superRefine((data, ctx) => { // Validate main phone number with country code // Validate representative phone for Korean companies if (data.country === "KR") { if (!data.representativeName) { ctx.addIssue({ code: "custom", path: ["representativeName"], message: "대표자 이름은 한국(KR) 업체일 경우 필수입니다.", }); } if (!data.representativeBirth) { ctx.addIssue({ code: "custom", path: ["representativeBirth"], message: "대표자 생년월일은 한국(KR) 업체일 경우 필수입니다.", }); } if (!data.representativeEmail) { ctx.addIssue({ code: "custom", path: ["representativeEmail"], message: "대표자 이메일은 한국(KR) 업체일 경우 필수입니다.", }); } if (!data.representativePhone) { ctx.addIssue({ code: "custom", path: ["representativePhone"], message: "대표자 전화번호는 한국(KR) 업체일 경우 필수입니다.", }); } else if (!validatePhoneByCountry(data.representativePhone, "KR")) { ctx.addIssue({ code: "custom", path: ["representativePhone"], message: "올바른 한국 전화번호 형식이 아닙니다. +82로 시작하거나 010으로 시작하는 번호를 입력해주세요.", }); } if (!data.corporateRegistrationNumber) { ctx.addIssue({ code: "custom", path: ["corporateRegistrationNumber"], message: "법인등록번호는 한국(KR) 업체일 경우 필수입니다.", }); } } // Validate contact phone numbers data.contacts?.forEach((contact, index) => { if (contact.contactPhone && data.country) { if (!validatePhoneByCountry(contact.contactPhone, data.country)) { const countryDialCode = countryDialCodes[data.country] || "+XX"; ctx.addIssue({ code: "custom", path: ["contacts", index, "contactPhone"], message: `올바른 전화번호 형식이 아닙니다. ${countryDialCode}로 시작하는 국제 전화번호를 입력해주세요.`, }); } } }); }); export const createVendorContactSchema = z.object({ vendorId: z.number(), contactName: z.string() .min(1, "Contact name is required") .max(255, "Max length 255"), // 신규 생성 시 반드시 입력 contactPosition: z.string().max(100, "Max length 100"), contactEmail: z.string().email(), contactPhone: z.string().max(50, "Max length 50").optional(), isPrimary: z.boolean(), }); export const updateVendorContactSchema = z.object({ contactName: z.string() .min(1, "Contact name is required") .max(255, "Max length 255"), // 신규 생성 시 반드시 입력 contactPosition: z.string().max(100, "Max length 100").optional(), contactEmail: z.string().email().optional(), contactPhone: z.string().max(50, "Max length 50").optional(), isPrimary: z.boolean().optional(), }); export const createVendorItemSchema = z.object({ vendorId: z.number(), itemCode: z.string().max(100, "Max length 100"), }); export const updateVendorItemSchema = z.object({ itemName: z.string().optional(), itemCode: z.string().max(100, "Max length 100"), description: z.string().optional() }); export const searchParamsRfqHistoryCache = createSearchParamsCache({ // 공통 플래그 flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( [] ), // 페이징 page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), // 정렬 sort: getSortingStateParser().withDefault([ { id: "createdAt", desc: true }, ]), // 고급 필터 filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), // 검색 키워드 search: parseAsString.withDefault(""), // RFQ 특화 필터 rfqCode: parseAsString.withDefault(""), projectCode: parseAsString.withDefault(""), projectName: parseAsString.withDefault(""), status: parseAsStringEnum(["DRAFT", "IN_PROGRESS", "COMPLETED", "CANCELLED"]), vendorStatus: parseAsStringEnum(["INVITED", "ACCEPTED", "DECLINED", "SUBMITTED", "AWARDED", "REJECTED"]), dueDate: parseAsString.withDefault(""), }); export type GetVendorsSchema = Awaited> export type GetVendorContactsSchema = Awaited> export type GetVendorItemsSchema = Awaited> export type UpdateVendorSchema = z.infer export type CreateVendorSchema = z.infer export type CreateVendorContactSchema = z.infer export type UpdateVendorContactSchema = z.infer export type CreateVendorItemSchema = z.infer export type UpdateVendorItemSchema = z.infer export type GetRfqHistorySchema = Awaited> export const updateVendorInfoSchema = z.object({ vendorName: z.string().min(1, "업체명은 필수 입력사항입니다."), taxId: z.string(), address: z.string().min(1, "주소는 필수 입력사항입니다."), addressDetail: z.string().optional(), postalCode: z.string().optional(), country: z.string().min(1, "국가를 선택해 주세요."), phone: z.string().optional(), email: z.string().email("유효한 이메일을 입력해 주세요."), website: z.string().optional(), // 한국 사업자 정보 (KR일 경우 필수 항목들) representativeName: z.string().optional(), representativeBirth: z.string().optional(), representativeEmail: z.string().optional(), representativePhone: z.string().optional(), corporateRegistrationNumber: z.string().optional(), // 신용평가 정보 creditAgency: z.string().optional(), creditRating: z.string().optional(), cashFlowRating: z.string().optional(), // 첨부파일 attachedFiles: z.any().optional(), creditRatingAttachment: z.any().optional(), cashFlowRatingAttachment: z.any().optional(), // 연락처 정보 contacts: z.array(contactSchema).min(1, "최소 1명의 담당자가 필요합니다."), }) export const updateVendorSchemaWithConditions = updateVendorInfoSchema.superRefine( (data, ctx) => { // 국가가 한국(KR)인 경우, 한국 사업자 정보 필수 if (data.country === "KR") { if (!data.representativeName) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "대표자 이름은 필수 입력사항입니다.", path: ["representativeName"], }) } if (!data.representativeBirth) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "대표자 생년월일은 필수 입력사항입니다.", path: ["representativeBirth"], }) } if (!data.representativeEmail) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "대표자 이메일은 필수 입력사항입니다.", path: ["representativeEmail"], }) } if (!data.representativePhone) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "대표자 전화번호는 필수 입력사항입니다.", path: ["representativePhone"], }) } if (!data.corporateRegistrationNumber) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "법인등록번호는 필수 입력사항입니다.", path: ["corporateRegistrationNumber"], }) } // 신용평가사가 선택된 경우, 등급 정보 필수 if (data.creditAgency) { if (!data.creditRating) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "신용평가등급은 필수 입력사항입니다.", path: ["creditRating"], }) } if (!data.cashFlowRating) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: "현금흐름등급은 필수 입력사항입니다.", path: ["cashFlowRating"], }) } } } } ) export type UpdateVendorInfoSchema = z.infer export const searchParamsMaterialCache = createSearchParamsCache({ // 공통 플래그 flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( [] ), // 페이징 page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), // 정렬 (vendors 테이블에 맞춰 Vendor 타입 지정) sort: getSortingStateParser().withDefault([ { id: "createdAt", desc: true }, // createdAt 기준 내림차순 ]), // 고급 필터 filters: getFiltersStateParser().withDefault([]), joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), // 검색 키워드 search: parseAsString.withDefault(""), itemName: parseAsString.withDefault(""), itemCode: parseAsString.withDefault(""), description: parseAsString.withDefault(""), }); export type GetVendorMaterialsSchema = Awaited>