diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/equip-class/repository.ts | 2 | ||||
| -rw-r--r-- | lib/equip-class/table/equipClass-table-columns.tsx | 143 | ||||
| -rw-r--r-- | lib/equip-class/table/equipClass-table.tsx | 27 | ||||
| -rw-r--r-- | lib/evaluation/service.ts | 179 | ||||
| -rw-r--r-- | lib/items-tech/service.ts | 236 | ||||
| -rw-r--r-- | lib/mail/sendEmail.ts | 9 | ||||
| -rw-r--r-- | lib/mail/templates/tech-vendor-invitation.hbs | 148 | ||||
| -rw-r--r-- | lib/mail/templates/vendor-evalution-request.hbs | 327 | ||||
| -rw-r--r-- | lib/sedp/get-form-tags.ts | 5 | ||||
| -rw-r--r-- | lib/sedp/get-tags.ts | 1 | ||||
| -rw-r--r-- | lib/sedp/sync-form.ts | 4 | ||||
| -rw-r--r-- | lib/sedp/sync-object-class.ts | 442 | ||||
| -rw-r--r-- | lib/tags/service.ts | 181 | ||||
| -rw-r--r-- | lib/tags/table/add-tag-dialog.tsx | 199 |
14 files changed, 1489 insertions, 414 deletions
diff --git a/lib/equip-class/repository.ts b/lib/equip-class/repository.ts index d4d6d58b..5513fb03 100644 --- a/lib/equip-class/repository.ts +++ b/lib/equip-class/repository.ts @@ -28,6 +28,8 @@ export async function selectTagClassLists( code: tagClasses.code, label: tagClasses.label, tagTypeCode: tagClasses.tagTypeCode, + subclassRemark: tagClasses.subclassRemark, + subclasses: tagClasses.subclasses, createdAt: tagClasses.createdAt, updatedAt: tagClasses.updatedAt, // 프로젝트 정보 추가 diff --git a/lib/equip-class/table/equipClass-table-columns.tsx b/lib/equip-class/table/equipClass-table-columns.tsx index 0dfbf06f..51aacc97 100644 --- a/lib/equip-class/table/equipClass-table-columns.tsx +++ b/lib/equip-class/table/equipClass-table-columns.tsx @@ -9,17 +9,125 @@ import { formatDate } from "@/lib/utils" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" import { equipclassColumnsConfig } from "@/config/equipClassColumnsConfig" import { ExtendedTagClasses } from "../validation" +import { Badge } from "@/components/ui/badge" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ExtendedTagClasses> | null>> } +// 서브클래스 배열을 렌더링하는 컴포넌트 +const SubclassesCell = ({ subclasses }: { subclasses: { + id: string; + desc: string; +}[] }) => { + if (!subclasses || subclasses.length === 0) { + return <span className="text-muted-foreground">-</span> + } + + if (subclasses.length <= 3) { + return ( + <div className="flex flex-wrap gap-1"> + {subclasses.map((subclass) => ( + <Badge key={subclass.id} variant="secondary" className="text-xs"> + {subclass.desc} + </Badge> + ))} + </div> + ) + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="flex flex-wrap gap-1"> + {subclasses.slice(0, 2).map((subclass) => ( + <Badge key={subclass.id} variant="secondary" className="text-xs"> + {subclass.desc} + </Badge> + ))} + <Badge variant="outline" className="text-xs"> + +{subclasses.length - 2} + </Badge> + </div> + </TooltipTrigger> + <TooltipContent> + <div className="max-w-sm"> + <div className="font-semibold mb-2">All Subclasses:</div> + <div className="flex flex-wrap gap-1"> + {subclasses.map((subclass) => ( + <Badge key={subclass.id} variant="secondary" className="text-xs"> + {subclass.desc} + </Badge> + ))} + </div> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) +} + +// 서브클래스 리마크를 렌더링하는 컴포넌트 +const SubclassRemarksCell = ({ remarks }: { remarks: Record<string, string> }) => { + if (!remarks || Object.keys(remarks).length === 0) { + return <span className="text-muted-foreground">-</span> + } + + const entries = Object.entries(remarks) + + if (entries.length <= 2) { + return ( + <div className="space-y-1"> + {entries.map(([key, value]) => ( + <div key={key} className="text-sm"> + <span className="font-medium">{key}:</span>{" "} + <span className="text-muted-foreground">{value}</span> + </div> + ))} + </div> + ) + } + + return ( + <TooltipProvider> + <Tooltip> + <TooltipTrigger asChild> + <div className="space-y-1"> + {entries.slice(0, 1).map(([key, value]) => ( + <div key={key} className="text-sm"> + <span className="font-medium">{key}:</span>{" "} + <span className="text-muted-foreground">{value}</span> + </div> + ))} + <div className="text-xs text-muted-foreground"> + +{entries.length - 1} more... + </div> + </div> + </TooltipTrigger> + <TooltipContent> + <div className="max-w-md"> + <div className="font-semibold mb-2">All Remarks:</div> + <div className="space-y-1"> + {entries.map(([key, value]) => ( + <div key={key} className="text-sm"> + <span className="font-medium">{key}:</span>{" "} + <span className="text-muted-foreground">{value}</span> + </div> + ))} + </div> + </div> + </TooltipContent> + </Tooltip> + </TooltipProvider> + ) +} + /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ExtendedTagClasses>[] { - - // ---------------------------------------------------------------- // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 // ---------------------------------------------------------------- @@ -47,12 +155,27 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Extende type: cfg.type, }, cell: ({ row, cell }) => { - - if (cfg.id === "createdAt"||cfg.id === "updatedAt") { + // 날짜 필드 처리 + if (cfg.id === "createdAt" || cfg.id === "updatedAt") { const dateVal = cell.getValue() as Date return formatDate(dateVal, "KR") } + // 서브클래스 배열 처리 + if (cfg.id === "subclasses") { + const subclasses = cell.getValue() as { + id: string; + desc: string; + }[] + return <SubclassesCell subclasses={subclasses} /> + } + + // 서브클래스 리마크 처리 + if (cfg.id === "subclassRemark") { + const remarks = cell.getValue() as Record<string, string> + return <SubclassRemarksCell remarks={remarks} /> + } + return row.getValue(cfg.id) ?? "" }, } @@ -65,9 +188,13 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Extende // ---------------------------------------------------------------- const nestedColumns: ColumnDef<ExtendedTagClasses>[] = [] - // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함 - // 여기서는 그냥 Object.entries 순서 - Object.entries(groupMap).forEach(([groupName, colDefs]) => { + // 그룹 순서 정의 + const groupOrder = ["Basic Info", "Hierarchy", "Metadata", "_noGroup"] + + groupOrder.forEach((groupName) => { + const colDefs = groupMap[groupName] + if (!colDefs) return + if (groupName === "_noGroup") { // 그룹 없음 → 그냥 최상위 레벨 컬럼 nestedColumns.push(...colDefs) @@ -75,7 +202,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Extende // 상위 컬럼 nestedColumns.push({ id: groupName, - header: groupName, // "Basic Info", "Metadata" 등 + header: groupName, // "Basic Info", "Hierarchy", "Metadata" 등 columns: colDefs, }) } diff --git a/lib/equip-class/table/equipClass-table.tsx b/lib/equip-class/table/equipClass-table.tsx index efb3a852..04348c54 100644 --- a/lib/equip-class/table/equipClass-table.tsx +++ b/lib/equip-class/table/equipClass-table.tsx @@ -51,7 +51,6 @@ export function EquipClassTable({ promises }: ItemsTableProps) { * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option. */ const filterFields: DataTableFilterField<ExtendedTagClasses>[] = [ - ] /** @@ -69,31 +68,41 @@ export function EquipClassTable({ promises }: ItemsTableProps) { id: "code", label: "Class ID", type: "text", - // group: "Basic Info", + group: "Basic Info", }, { id: "label", label: "Description", type: "text", - // group: "Basic Info", + group: "Basic Info", + }, + { + id: "tagTypeCode", + label: "Type ID", + type: "text", + group: "Basic Info", + }, + // 서브클래스 관련 필터 추가 + { + id: "subclasses", + label: "Subclasses", + type: "text", + group: "Hierarchy", }, - - { id: "createdAt", label: "Created At", type: "date", - // group: "Metadata",a + group: "Metadata", }, { id: "updatedAt", label: "Updated At", type: "date", - // group: "Metadata", + group: "Metadata", }, ] - const { table } = useDataTable({ data, columns, @@ -125,4 +134,4 @@ export function EquipClassTable({ promises }: ItemsTableProps) { </DataTable> </> ) -} +}
\ No newline at end of file diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts index 3e85b4a2..c49521da 100644 --- a/lib/evaluation/service.ts +++ b/lib/evaluation/service.ts @@ -14,7 +14,10 @@ import { reviewerEvaluations, roles, userRoles, + userView, users, + vendorContacts, + vendors, type PeriodicEvaluationView } from "@/db/schema" import { @@ -23,7 +26,7 @@ import { count, desc, ilike, - or, sql, eq, avg, inArray,like, + or, sql, eq, avg, inArray, like, type SQL } from "drizzle-orm" import { filterColumns } from "@/lib/filter-columns" @@ -34,6 +37,8 @@ import { DEPARTMENT_CODE_LABELS } from "@/types/evaluation" import { getServerSession } from "next-auth" import { authOptions } from "@/app/api/auth/[...nextauth]/route" import { AttachmentDetail, EvaluationDetailResponse } from "@/types/evaluation-form" +import { headers } from 'next/headers'; + export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) { try { @@ -306,6 +311,8 @@ interface RequestDocumentsData { evaluationYear: number evaluationRound: string message: string + dueDate?: string; // 선택적 필드로 추가 + } export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) { @@ -319,7 +326,7 @@ export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) eq(evaluationSubmissions.periodicEvaluationId, item.periodicEvaluationId), eq(evaluationSubmissions.companyId, item.companyId) ) - }) + }); if (existingSubmission) { // 이미 존재하면 reviewComments만 업데이트 @@ -330,9 +337,9 @@ export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) updatedAt: new Date() }) .where(eq(evaluationSubmissions.id, existingSubmission.id)) - .returning() + .returning(); - return updated + return updated; } else { // 새로 생성 const [created] = await db @@ -351,16 +358,16 @@ export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) completedEsgItems: 0, isActive: true }) - .returning() + .returning(); - return created + return created; } }) - ) + ); // periodic_evaluations 테이블의 status를 PENDING_SUBMISSION으로 업데이트 - const periodicEvaluationIds = [...new Set(data.map(item => item.periodicEvaluationId))] - + const periodicEvaluationIds = [...new Set(data.map(item => item.periodicEvaluationId))]; + await Promise.all( periodicEvaluationIds.map(async (periodicEvaluationId) => { await db @@ -369,23 +376,115 @@ export async function requestDocumentsFromVendors(data: RequestDocumentsData[]) status: 'PENDING_SUBMISSION', updatedAt: new Date() }) - .where(eq(periodicEvaluations.id, periodicEvaluationId)) + .where(eq(periodicEvaluations.id, periodicEvaluationId)); }) - ) + ); + + // 📧 이메일 발송 로직 추가 + const emailResults = await Promise.allSettled( + data.map(async (item) => { + // 해당 vendor의 정보와 연락처 정보 조회 + const vendorInfo = await db.query.vendors.findFirst({ + where: eq(vendors.id, item.companyId), + with: { + contacts: { + where: eq(vendorContacts.isPrimary, true), // 주 연락처 우선 + limit: 1 + } + } + }); + + if (!vendorInfo) { + throw new Error(`Vendor not found for companyId: ${item.companyId}`); + } + + // 이메일 주소 결정: 주 연락처 > vendor 이메일 + const emailAddress = vendorInfo.email; + + if (!emailAddress) { + throw new Error(`No email address found for vendor: ${vendorInfo.vendorName}`); + } + + // CC 이메일 주소들 수집 (주 연락처가 아닌 다른 연락처들) + const allContacts = await db.query.vendorContacts.findMany({ + where: and( + eq(vendorContacts.vendorId, item.companyId), + eq(vendorContacts.isPrimary, false) + ) + }); + + const ccEmails = allContacts + .map(contact => contact.contactEmail) + .filter(email => email && email !== emailAddress); + + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + const baseUrl = `http://${host}` + + // 정기평가 담당자 이메일 조회 + const evaluationManagers = await db + .select() + .from(userView) + .where(sql`'정기평가' = ANY(${userView.roles})`) + .limit(1); + + const evaluationManager = evaluationManagers[0]; + const supportEmail = evaluationManager?.user_email ||process.env.Email_From_Address ||'vendor-support@samsung.com'; + + // 이메일 템플릿 데이터 준비 + const templateData = { + companyName: vendorInfo.vendorName, + evaluationYear: item.evaluationYear, + evaluationRound: item.evaluationRound, + requestDate: new Date().toLocaleDateString('ko-KR'), + dueDate: item.dueDate ? new Date(item.dueDate).toLocaleDateString('ko-KR') : null, + reviewComments: item.message, + accessUrl:`${baseUrl}/partners/evaluation`, + supportEmail:supportEmail, + businessHours: '평일 8:00-17:00 (한국시간)' + }; + + // 이메일 발송 + return await sendEmail({ + to: emailAddress, + cc: ccEmails.length > 0 ? ccEmails : undefined, + subject: `[SHI] ${item.evaluationYear}년 ${item.evaluationRound || ''} 협력업체 평가 자료 요청`, + template: 'vendor-evalution-request', + context: templateData + }); + }) + ); + + // 이메일 발송 결과 분석 + const emailSuccessCount = emailResults.filter(result => result.status === 'fulfilled').length; + const emailFailures = emailResults + .filter(result => result.status === 'rejected') + .map(result => (result as PromiseRejectedResult).reason); + + // 실패한 이메일이 있으면 로그에 기록 + if (emailFailures.length > 0) { + console.error('이메일 발송 실패:', emailFailures); + } return { success: true, message: `${submissions.length}개 업체에 자료 요청이 완료되었습니다.`, + emailResults: { + totalSent: emailSuccessCount, + totalFailed: emailFailures.length, + failures: emailFailures + }, submissions - } + }; } catch (error) { - console.error("Error requesting documents from vendors:", error) + console.error("Error requesting documents from vendors:", error); return { success: false, message: "자료 요청 중 오류가 발생했습니다.", error: error instanceof Error ? error.message : "Unknown error" - } + }; } } // 기존 요청 상태 확인 함수 추가 @@ -491,7 +590,7 @@ export async function getReviewersForEvaluations( // 3. role 기반 리뷰어들을 각 evaluationTargetId에 대해 확장 const expandedRoleBasedReviewers: ReviewerInfo[] = [] - + for (const evaluationTargetId of evaluationTargetIds) { for (const reviewer of roleBasedReviewers) { expandedRoleBasedReviewers.push({ @@ -508,11 +607,11 @@ export async function getReviewersForEvaluations( // 4. 중복 제거 (같은 사용자가 designated reviewer와 role-based reviewer 모두에 있을 수 있음) const allReviewers = [...designatedReviewers, ...expandedRoleBasedReviewers] - + // evaluationTargetId + userId 조합으로 중복 제거 const uniqueReviewers = allReviewers.reduce((acc, reviewer) => { const key = `${reviewer.evaluationTargetId}-${reviewer.id}` - + // 이미 있는 경우 designated reviewer를 우선 (evaluationTargetReviewerId가 양수인 것) if (acc[key]) { if (reviewer.evaluationTargetReviewerId > 0) { @@ -522,7 +621,7 @@ export async function getReviewersForEvaluations( } else { acc[key] = reviewer } - + return acc }, {} as Record<string, ReviewerInfo>) @@ -714,8 +813,6 @@ async function sendEvaluationRequestEmails( } }) - console.log('평가 그룹:', evaluationGroups) - // 4. 각 리뷰어에게 개별 이메일 발송 const emailPromises = [] @@ -730,6 +827,10 @@ async function sendEvaluationRequestEmails( const otherReviewers = group.reviewers.filter(r => r?.evaluationTargetReviewerId !== reviewer.evaluationTargetReviewerId) console.log(`${reviewer.userName}(${reviewer.userEmail})에게 이메일 발송 준비`) + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + + const baseUrl = `http://${host}` const emailPromise = sendEmail({ to: reviewer.userEmail, @@ -755,7 +856,7 @@ async function sendEvaluationRequestEmails( email: r?.userEmail })).filter(r => r.name), message: message || "협력업체 정기평가를 진행해 주시기 바랍니다.", - evaluationUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/evaluations/${group.periodicEvaluationId}/review` + evaluationUrl: `${baseUrl}/procurement/evaluation-input/${group.periodicEvaluationId}` }, }).catch(error => { console.error(`${reviewer.userEmail}에게 이메일 발송 실패:`, error) @@ -828,7 +929,7 @@ export async function getReviewerEvaluationStatus( interface FinalizeEvaluationData { id: number finalScore: number - finalGrade: "S" | "A" | "B" | "C" | "D" + finalGrade: "A" | "B" | "C" | "D" } /** @@ -978,7 +1079,7 @@ export interface EvaluationDetailData { isCompleted: boolean completedAt: Date | null reviewerComment: string | null - + // 평가 항목별 상세 evaluationItems: { // 평가 기준 정보 @@ -990,11 +1091,11 @@ export interface EvaluationDetailData { range: string | null remarks: string | null scoreType: string - + // 선택된 옵션 정보 (fixed 타입인 경우) selectedDetailId: number | null selectedDetail: string | null - + // 점수 및 의견 score: number | null comment: string | null @@ -1012,7 +1113,7 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis .select({ id: periodicEvaluations.id, vendorName: evaluationTargets.vendorName, - vendorCode: evaluationTargets.vendorCode, + vendorCode: evaluationTargets.vendorCode, evaluationYear: evaluationTargets.evaluationYear, division: evaluationTargets.division, status: periodicEvaluations.status, @@ -1037,7 +1138,7 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis isCompleted: reviewerEvaluations.isCompleted, completedAt: reviewerEvaluations.completedAt, reviewerComment: reviewerEvaluations.reviewerComment, - + // 평가 항목 상세 detailId: reviewerEvaluationDetails.id, criteriaId: regEvalCriteria.id, @@ -1048,11 +1149,11 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis range: regEvalCriteria.range, remarks: regEvalCriteria.remarks, scoreType: regEvalCriteria.scoreType, - + // 선택된 옵션 정보 selectedDetailId: reviewerEvaluationDetails.regEvalCriteriaDetailsId, selectedDetail: regEvalCriteriaDetails.detail, - + // 점수 및 의견 score: reviewerEvaluationDetails.score, comment: reviewerEvaluationDetails.comment, @@ -1080,14 +1181,14 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis description: reviewerEvaluationAttachments.description, uploadedBy: reviewerEvaluationAttachments.uploadedBy, attachmentCreatedAt: reviewerEvaluationAttachments.createdAt, - + // 업로드한 사용자 정보 uploadedByName: users.name, - + // 평가 세부사항 정보 evaluationDetailId: reviewerEvaluationDetails.id, reviewerEvaluationId: reviewerEvaluationDetails.reviewerEvaluationId, - + // 평가 기준 정보 (질문 식별용) criteriaId: regEvalCriteriaDetails.criteriaId, }) @@ -1149,7 +1250,7 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis reviewerDetailsRaw.forEach(row => { if (!reviewerDetailsMap.has(row.reviewerEvaluationId)) { const reviewerAttachments = attachmentsByReviewerId.get(row.reviewerEvaluationId) || [] - + reviewerDetailsMap.set(row.reviewerEvaluationId, { reviewerEvaluationId: row.reviewerEvaluationId, reviewerName: row.reviewerName || "", @@ -1160,11 +1261,11 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis completedAt: row.completedAt, reviewerComment: row.reviewerComment, evaluationItems: [], - + // 📎 리뷰어별 첨부파일 통계 totalAttachments: reviewerAttachments.length, totalAttachmentSize: reviewerAttachments.reduce((sum, att) => sum + att.fileSize, 0), - questionsWithAttachments: new Set(reviewerAttachments.map(att => + questionsWithAttachments: new Set(reviewerAttachments.map(att => attachmentsData.find(a => a.attachmentId === att.id)?.criteriaId ).filter(Boolean)).size, }) @@ -1174,7 +1275,7 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis if (row.criteriaId && row.detailId) { const reviewer = reviewerDetailsMap.get(row.reviewerEvaluationId)! const itemAttachments = attachmentsByDetailId.get(row.detailId) || [] - + reviewer.evaluationItems.push({ criteriaId: row.criteriaId, category: row.category || "", @@ -1188,7 +1289,7 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis selectedDetail: row.selectedDetail, score: row.score ? Number(row.score) : null, comment: row.comment, - + // 📎 항목별 첨부파일 정보 attachments: itemAttachments, attachmentCount: itemAttachments.length, @@ -1214,8 +1315,8 @@ export async function getEvaluationDetails(periodicEvaluationId: number): Promis } catch (error) { console.error("Error fetching evaluation details:", error) throw new Error( - error instanceof Error - ? error.message + error instanceof Error + ? error.message : "평가 상세 정보 조회 중 오류가 발생했습니다" ) } diff --git a/lib/items-tech/service.ts b/lib/items-tech/service.ts index 0cc08d23..bf2684d7 100644 --- a/lib/items-tech/service.ts +++ b/lib/items-tech/service.ts @@ -13,9 +13,9 @@ import { GetShipbuildingSchema, GetOffshoreTopSchema, GetOffshoreHullSchema, Shi import { itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
// 타입 정의 추가
-export type ShipbuildingWorkType = '기장' | '전장' | '선실' | '배관' | '철의';
+export type ShipbuildingWorkType = '기장' | '전장' | '선실' | '배관' | '철의' | '선체';
export type OffshoreTopWorkType = 'TM' | 'TS' | 'TE' | 'TP';
-export type OffshoreHullWorkType = 'HA' | 'HE' | 'HH' | 'HM' | 'NC';
+export type OffshoreHullWorkType = 'HA' | 'HE' | 'HH' | 'HM' | 'HO' | 'HP' | 'NC';
export interface ShipbuildingItem {
id: number;
@@ -62,7 +62,7 @@ export async function getShipbuildingItems(input: GetShipbuildingSchema) { try {
const offset = (input.page - 1) * input.perPage;
- // advancedTable 모드면 filterColumns()로 where 절 구성
+ // advancedTable 모드면 filterColumns()로 where 절 구성 (기존 필터)
const advancedWhere = filterColumns({
table: itemShipbuilding,
filters: input.filters.filter(filter => {
@@ -73,6 +73,17 @@ export async function getShipbuildingItems(input: GetShipbuildingSchema) { joinOperator: input.joinOperator,
});
+ // 필터 시트에서 온 shipFilters 처리
+ const shipFilterWhere = filterColumns({
+ table: itemShipbuilding,
+ filters: input.shipFilters?.filter(filter => {
+ // enum 필드에 대한 isEmpty/isNotEmpty는 제외
+ return !((filter.id === 'workType' || filter.id === 'shipTypes') &&
+ (filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
+ }) || [],
+ joinOperator: input.shipJoinOperator,
+ });
+
let globalWhere;
if (input.search) {
const s = `%${input.search}%`;
@@ -82,7 +93,7 @@ export async function getShipbuildingItems(input: GetShipbuildingSchema) { );
}
- // enum 필드에 대한 isEmpty/isNotEmpty 처리
+ // enum 필드에 대한 isEmpty/isNotEmpty 처리 (기존 필터)
const enumConditions = input.filters
.filter(filter => (filter.id === 'workType' || filter.id === 'shipTypes') &&
(filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
@@ -91,10 +102,21 @@ export async function getShipbuildingItems(input: GetShipbuildingSchema) { return filter.operator === 'isEmpty' ? sql`${column} is null` : sql`${column} is not null`;
});
+ // enum 필드에 대한 isEmpty/isNotEmpty 처리 (필터 시트)
+ const shipEnumConditions = input.shipFilters
+ ?.filter(filter => (filter.id === 'workType' || filter.id === 'shipTypes') &&
+ (filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
+ .map(filter => {
+ const column = itemShipbuilding[filter.id];
+ return filter.operator === 'isEmpty' ? sql`${column} is null` : sql`${column} is not null`;
+ }) || [];
+
const finalWhere = and(
advancedWhere,
+ shipFilterWhere,
globalWhere,
- ...enumConditions
+ ...enumConditions,
+ ...shipEnumConditions
);
const where = finalWhere;
@@ -152,7 +174,7 @@ export async function getOffshoreTopItems(input: GetOffshoreTopSchema) { try {
const offset = (input.page - 1) * input.perPage;
- // advancedTable 모드면 filterColumns()로 where 절 구성
+ // advancedTable 모드면 filterColumns()로 where 절 구성 (기존 필터)
const advancedWhere = filterColumns({
table: itemOffshoreTop,
filters: input.filters.filter(filter => {
@@ -163,6 +185,17 @@ export async function getOffshoreTopItems(input: GetOffshoreTopSchema) { joinOperator: input.joinOperator,
});
+ // 필터 시트에서 온 topFilters 처리
+ const topFilterWhere = filterColumns({
+ table: itemOffshoreTop,
+ filters: input.topFilters?.filter(filter => {
+ // enum 필드에 대한 isEmpty/isNotEmpty는 제외
+ return !((filter.id === 'workType') &&
+ (filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
+ }) || [],
+ joinOperator: input.topJoinOperator,
+ });
+
let globalWhere;
if (input.search) {
const s = `%${input.search}%`;
@@ -173,7 +206,7 @@ export async function getOffshoreTopItems(input: GetOffshoreTopSchema) { );
}
- // enum 필드에 대한 isEmpty/isNotEmpty 처리
+ // enum 필드에 대한 isEmpty/isNotEmpty 처리 (기존 필터)
const enumConditions = input.filters
.filter(filter => (filter.id === 'workType') &&
(filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
@@ -182,10 +215,21 @@ export async function getOffshoreTopItems(input: GetOffshoreTopSchema) { return filter.operator === 'isEmpty' ? sql`${column} is null` : sql`${column} is not null`;
});
+ // enum 필드에 대한 isEmpty/isNotEmpty 처리 (필터 시트)
+ const topEnumConditions = input.topFilters
+ ?.filter(filter => (filter.id === 'workType') &&
+ (filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
+ .map(filter => {
+ const column = itemOffshoreTop.workType;
+ return filter.operator === 'isEmpty' ? sql`${column} is null` : sql`${column} is not null`;
+ }) || [];
+
const finalWhere = and(
advancedWhere,
+ topFilterWhere,
globalWhere,
- ...enumConditions
+ ...enumConditions,
+ ...topEnumConditions
);
const where = finalWhere;
@@ -243,7 +287,7 @@ export async function getOffshoreHullItems(input: GetOffshoreHullSchema) { try {
const offset = (input.page - 1) * input.perPage;
- // advancedTable 모드면 filterColumns()로 where 절 구성
+ // advancedTable 모드면 filterColumns()로 where 절 구성 (기존 필터)
const advancedWhere = filterColumns({
table: itemOffshoreHull,
filters: input.filters.filter(filter => {
@@ -254,6 +298,17 @@ export async function getOffshoreHullItems(input: GetOffshoreHullSchema) { joinOperator: input.joinOperator,
});
+ // 필터 시트에서 온 hullFilters 처리
+ const hullFilterWhere = filterColumns({
+ table: itemOffshoreHull,
+ filters: input.hullFilters?.filter(filter => {
+ // enum 필드에 대한 isEmpty/isNotEmpty는 제외
+ return !((filter.id === 'workType') &&
+ (filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
+ }) || [],
+ joinOperator: input.hullJoinOperator,
+ });
+
let globalWhere;
if (input.search) {
const s = `%${input.search}%`;
@@ -264,7 +319,7 @@ export async function getOffshoreHullItems(input: GetOffshoreHullSchema) { );
}
- // enum 필드에 대한 isEmpty/isNotEmpty 처리
+ // enum 필드에 대한 isEmpty/isNotEmpty 처리 (기존 필터)
const enumConditions = input.filters
.filter(filter => (filter.id === 'workType') &&
(filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
@@ -273,10 +328,21 @@ export async function getOffshoreHullItems(input: GetOffshoreHullSchema) { return filter.operator === 'isEmpty' ? sql`${column} is null` : sql`${column} is not null`;
});
+ // enum 필드에 대한 isEmpty/isNotEmpty 처리 (필터 시트)
+ const hullEnumConditions = input.hullFilters
+ ?.filter(filter => (filter.id === 'workType') &&
+ (filter.operator === 'isEmpty' || filter.operator === 'isNotEmpty'))
+ .map(filter => {
+ const column = itemOffshoreHull.workType;
+ return filter.operator === 'isEmpty' ? sql`${column} is null` : sql`${column} is not null`;
+ }) || [];
+
const finalWhere = and(
advancedWhere,
+ hullFilterWhere,
globalWhere,
- ...enumConditions
+ ...enumConditions,
+ ...hullEnumConditions
);
const where = finalWhere;
@@ -339,19 +405,12 @@ export async function createShipbuildingItem(input: TypedItemCreateData) { unstable_noStore()
try {
- if (!input.itemCode) {
- return {
- success: false,
- message: "아이템 코드는 필수입니다",
- data: null,
- error: "필수 필드 누락"
- }
- }
+ // itemCode는 nullable하게 변경
const shipData = input as ShipbuildingItemCreateData;
const result = await db.insert(itemShipbuilding).values({
- itemCode: input.itemCode,
- workType: shipData.workType ? (shipData.workType as '기장' | '전장' | '선실' | '배관' | '철의') : '기장',
+ itemCode: input.itemCode || "",
+ workType: shipData.workType ? (shipData.workType as '기장' | '전장' | '선실' | '배관' | '철의' | '선체') : '기장',
shipTypes: shipData.shipTypes || '',
itemList: shipData.itemList || null,
createdAt: new Date(),
@@ -391,8 +450,8 @@ export async function createShipbuildingItem(input: TypedItemCreateData) { * 하나의 아이템 코드에 대해 여러 선종을 처리 (1:N 관계)
*/
export async function createShipbuildingImportItem(input: {
- itemCode: string;
- workType: '기장' | '전장' | '선실' | '배관' | '철의';
+ itemCode?: string | null;
+ workType: '기장' | '전장' | '선실' | '배관' | '철의' | '선체';
itemList?: string | null;
subItemList?: string | null;
shipTypes?: string | null;
@@ -400,35 +459,30 @@ export async function createShipbuildingImportItem(input: { unstable_noStore();
try {
- if (!input.itemCode) {
- return {
- success: false,
- message: "아이템 코드는 필수입니다",
- data: null,
- error: "필수 필드 누락"
- }
- }
+ // itemCode는 nullable하게 변경
- // 기존 아이템 및 선종 확인
- const existingItem = await db.select().from(itemShipbuilding)
- .where(
- and(
- eq(itemShipbuilding.itemCode, input.itemCode),
- eq(itemShipbuilding.shipTypes, input.shipTypes || '')
- )
- );
-
- if (existingItem.length > 0) {
- return {
- success: false,
- message: "이미 존재하는 아이템 코드 및 선종입니다",
- data: null,
- error: "중복 키 오류"
+ // 기존 아이템 및 선종 확인 (itemCode가 있을 경우에만)
+ if (input.itemCode) {
+ const existingItem = await db.select().from(itemShipbuilding)
+ .where(
+ and(
+ eq(itemShipbuilding.itemCode, input.itemCode),
+ eq(itemShipbuilding.shipTypes, input.shipTypes || '')
+ )
+ );
+
+ if (existingItem.length > 0) {
+ return {
+ success: false,
+ message: "이미 존재하는 아이템 코드 및 선종입니다",
+ data: null,
+ error: "중복 키 오류"
+ }
}
}
const result = await db.insert(itemShipbuilding).values({
- itemCode: input.itemCode,
+ itemCode: input.itemCode || "",
workType: input.workType,
shipTypes: input.shipTypes || '',
itemList: input.itemList || '',
@@ -464,21 +518,32 @@ export async function createShipbuildingImportItem(input: { }
}
+/**
+ * 해양 TOP 아이템 생성 (중복 아이템코드 방지)
+ */
export async function createOffshoreTopItem(data: OffshoreTopItemCreateData) {
- unstable_noStore()
-
+ unstable_noStore();
+
try {
- if (!data.itemCode) {
- return {
- success: false,
- message: "아이템 코드는 필수입니다",
- data: null,
- error: "필수 필드 누락"
+ // itemCode가 있는 경우 중복 체크
+ if (data.itemCode && data.itemCode.trim() !== "") {
+ const existingItem = await db
+ .select({ id: itemOffshoreTop.id })
+ .from(itemOffshoreTop)
+ .where(eq(itemOffshoreTop.itemCode, data.itemCode.trim()));
+
+ if (existingItem.length > 0) {
+ return {
+ success: false,
+ message: "이미 존재하는 아이템 코드입니다",
+ data: null,
+ error: "중복 키 오류"
+ };
}
}
-
+
const result = await db.insert(itemOffshoreTop).values({
- itemCode: data.itemCode,
+ itemCode: data.itemCode || "",
workType: data.workType,
itemList: data.itemList,
subItemList: data.subItemList,
@@ -486,23 +551,23 @@ export async function createOffshoreTopItem(data: OffshoreTopItemCreateData) { updatedAt: new Date()
}).returning();
- revalidateTag("items")
+ revalidateTag("items");
return {
success: true,
data: result[0],
error: null
- }
+ };
} catch (err) {
- console.error("아이템 생성 오류:", err)
-
+ console.error("해양 TOP 아이템 생성 오류:", err);
+
if (err instanceof Error && err.message.includes("unique constraint")) {
return {
success: false,
message: "이미 존재하는 아이템 코드입니다",
data: null,
error: "중복 키 오류"
- }
+ };
}
return {
@@ -510,25 +575,36 @@ export async function createOffshoreTopItem(data: OffshoreTopItemCreateData) { message: getErrorMessage(err),
data: null,
error: getErrorMessage(err)
- }
+ };
}
}
+/**
+ * 해양 HULL 아이템 생성 (중복 아이템코드 방지)
+ */
export async function createOffshoreHullItem(data: OffshoreHullItemCreateData) {
- unstable_noStore()
-
+ unstable_noStore();
+
try {
- if (!data.itemCode) {
- return {
- success: false,
- message: "아이템 코드는 필수입니다",
- data: null,
- error: "필수 필드 누락"
+ // itemCode가 있는 경우 중복 체크
+ if (data.itemCode && data.itemCode.trim() !== "") {
+ const existingItem = await db
+ .select({ id: itemOffshoreHull.id })
+ .from(itemOffshoreHull)
+ .where(eq(itemOffshoreHull.itemCode, data.itemCode.trim()));
+
+ if (existingItem.length > 0) {
+ return {
+ success: false,
+ message: "이미 존재하는 아이템 코드입니다",
+ data: null,
+ error: "중복 키 오류"
+ };
}
}
-
+
const result = await db.insert(itemOffshoreHull).values({
- itemCode: data.itemCode,
+ itemCode: data.itemCode || "",
workType: data.workType,
itemList: data.itemList,
subItemList: data.subItemList,
@@ -536,23 +612,23 @@ export async function createOffshoreHullItem(data: OffshoreHullItemCreateData) { updatedAt: new Date()
}).returning();
- revalidateTag("items")
+ revalidateTag("items");
return {
success: true,
data: result[0],
error: null
- }
+ };
} catch (err) {
- console.error("아이템 생성 오류:", err)
-
+ console.error("해양 HULL 아이템 생성 오류:", err);
+
if (err instanceof Error && err.message.includes("unique constraint")) {
return {
success: false,
message: "이미 존재하는 아이템 코드입니다",
data: null,
error: "중복 키 오류"
- }
+ };
}
return {
@@ -560,7 +636,7 @@ export async function createOffshoreHullItem(data: OffshoreHullItemCreateData) { message: getErrorMessage(err),
data: null,
error: getErrorMessage(err)
- }
+ };
}
}
@@ -1126,6 +1202,8 @@ export async function getOffshoreHullWorkTypes() { { code: 'HE' as OffshoreHullWorkType, name: 'HE'},
{ code: 'HH' as OffshoreHullWorkType, name: 'HH'},
{ code: 'HM' as OffshoreHullWorkType, name: 'HM'},
+ { code: 'HO' as OffshoreHullWorkType, name: 'HO'},
+ { code: 'HP' as OffshoreHullWorkType, name: 'HP'},
{ code: 'NC' as OffshoreHullWorkType, name: 'NC'},
]
}
diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts index b4d2707a..3b358ea8 100644 --- a/lib/mail/sendEmail.ts +++ b/lib/mail/sendEmail.ts @@ -8,6 +8,7 @@ interface SendEmailOptions { template: string; // 템플릿 파일명(확장자 제외) context: Record<string, any>; // 템플릿에 주입할 데이터 cc?: string | string[]; // cc 필드 추가 - 단일 이메일 또는 이메일 배열 + from?: string; // from 필드 추가 - 옵셔널 attachments?: { // NodeMailer "Attachment" 타입 filename?: string @@ -23,6 +24,7 @@ export async function sendEmail({ template, context, cc, // cc 매개변수 추가 + from, // from 매개변수 추가 attachments = [] }: SendEmailOptions) { try { @@ -47,9 +49,12 @@ export async function sendEmail({ // 템플릿 컴파일 및 HTML 생성 const html = loadTemplate(template, templateData); + // from 값 설정 - 매개변수가 있으면 사용, 없으면 기본값 사용 + const fromAddress = from || `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`; + // 이메일 발송 const result = await transporter.sendMail({ - from: `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`, + from: fromAddress, to, cc, // cc 필드 추가 subject, @@ -59,7 +64,7 @@ export async function sendEmail({ console.log(`이메일 발송 성공: ${to}`, result.messageId); return result; - + } catch (error) { console.error(`이메일 발송 실패: ${to}`, error); throw error; diff --git a/lib/mail/templates/tech-vendor-invitation.hbs b/lib/mail/templates/tech-vendor-invitation.hbs new file mode 100644 index 00000000..cabd884e --- /dev/null +++ b/lib/mail/templates/tech-vendor-invitation.hbs @@ -0,0 +1,148 @@ +<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>eVCP 기술영업 협력업체 등록 초대</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ line-height: 1.6;
+ }
+ .email-container {
+ max-width: 600px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 30px;
+ border-radius: 10px;
+ box-shadow: 0 4px 15px rgba(0,0,0,0.1);
+ }
+ .header {
+ border-bottom: 3px solid #163CC4;
+ padding-bottom: 20px;
+ margin-bottom: 30px;
+ }
+ .logo {
+ color: #163CC4;
+ font-weight: bold;
+ font-size: 36px;
+ margin: 0;
+ }
+ .title {
+ color: #1f2937;
+ font-size: 28px;
+ font-weight: 600;
+ margin: 0 0 20px 0;
+ }
+ .content {
+ color: #374151;
+ font-size: 16px;
+ line-height: 1.7;
+ margin-bottom: 16px;
+ }
+ .highlight-box {
+ background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%);
+ border-left: 4px solid #163CC4;
+ border-radius: 8px;
+ padding: 20px;
+ margin: 25px 0;
+ }
+ .cta-button {
+ display: inline-block;
+ background: linear-gradient(135deg, #163CC4 0%, #1e40af 100%);
+ color: #ffffff !important;
+ text-decoration: none;
+ padding: 15px 35px;
+ border-radius: 8px;
+ font-weight: 600;
+ font-size: 16px;
+ text-align: center;
+ transition: all 0.3s ease;
+ box-shadow: 0 4px 12px rgba(22, 60, 196, 0.3);
+ }
+ .footer {
+ border-top: 1px solid #e5e7eb;
+ padding-top: 20px;
+ margin-top: 40px;
+ text-align: center;
+ }
+ .footer-text {
+ color: #6b7280;
+ font-size: 14px;
+ margin: 8px 0;
+ }
+ </style>
+</head>
+<body>
+ <div class="email-container">
+ <div class="header">
+ <h1 class="logo">eVCP</h1>
+ </div>
+
+ <h1 class="title">기술영업 협력업체 등록 초대</h1>
+
+ <p class="content">
+ <strong>{{companyName}}</strong> 귀하,
+ </p>
+
+ <p class="content">
+ 안녕하세요. 삼성중공업 eVCP 시스템입니다.
+ </p>
+
+ <p class="content">
+ 귀사를 저희 <strong>eVCP 기술영업 협력업체</strong>로 등록하도록 정중히 초대합니다.
+ 기술영업 협력업체로 등록하시면 다음과 같은 혜택을 누리실 수 있습니다:
+ </p>
+
+ <div class="highlight-box">
+ <ul style="margin: 0; padding-left: 20px; color: #374151;">
+ <li style="margin-bottom: 8px;">기술 제안서(RFQ) 참여 기회</li>
+ <li style="margin-bottom: 8px;">기술 검토 및 평가 프로세스 참여</li>
+ <li style="margin-bottom: 8px;">조선/해양 프로젝트 기술영업 참여</li>
+ <li style="margin-bottom: 8px;">온라인 플랫폼을 통한 효율적인 업무 처리</li>
+ </ul>
+ </div>
+
+ <p class="content">
+ 아래 버튼을 클릭하여 기술영업 협력업체 등록을 완료해 주세요.
+ 등록 포털에서 귀사의 기본 정보, 기술 역량, 연락처 등을 입력하실 수 있습니다.
+ </p>
+
+ <p style="text-align: center; margin: 30px 0;">
+ <a href="{{registrationLink}}" target="_blank" class="cta-button">
+ 📝 기술영업 협력업체 등록하기
+ </a>
+ </p>
+
+ <div style="background-color: #fef3c7; border: 1px solid #f59e0b; border-radius: 6px; padding: 15px; margin: 25px 0;">
+ <p style="margin: 0; color: #92400e; font-size: 14px;">
+ ⚠️ <strong>중요:</strong> 이 초대 링크는 <strong>7일 후</strong>에 만료됩니다.
+ 가능한 빠른 시일 내에 등록을 완료해 주세요.
+ </p>
+ </div>
+
+ <p class="content">
+ 등록 과정에서 문제가 발생하거나 궁금한 사항이 있으시면,
+ 언제든지 <strong>support@evcp.com</strong>으로 연락 주시기 바랍니다.
+ </p>
+
+ <p class="content">
+ 귀사와 함께 성공적인 파트너십을 구축하고,
+ 저희 기술영업 협력업체 네트워크의 소중한 일원이 되어 주시기를 기대합니다.
+ </p>
+
+ <p class="content" style="margin-top: 30px;">
+ 감사합니다.<br>
+ <strong>삼성중공업 eVCP 기술영업팀</strong>
+ </p>
+
+ <div class="footer">
+ <p class="footer-text">© 2024 Samsung Heavy Industries eVCP. All rights reserved.</p>
+ <p class="footer-text">이 메일은 자동으로 발송된 메일입니다. 회신하지 마시기 바랍니다.</p>
+ </div>
+ </div>
+</body>
+</html>
\ No newline at end of file diff --git a/lib/mail/templates/vendor-evalution-request.hbs b/lib/mail/templates/vendor-evalution-request.hbs new file mode 100644 index 00000000..7eef3f29 --- /dev/null +++ b/lib/mail/templates/vendor-evalution-request.hbs @@ -0,0 +1,327 @@ +<!DOCTYPE html> +<html lang="ko"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>협력업체 평가 자료 요청 | Vendor Evaluation Document Request</title> + <style> + body { + font-family: 'Apple SD Gothic Neo', 'Malgun Gothic', 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; + color: #333; + max-width: 650px; + margin: 0 auto; + padding: 20px; + background-color: #f8f9fa; + } + .email-container { + background: white; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0,0,0,0.08); + overflow: hidden; + } + .header { + background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%); + color: white; + padding: 30px; + text-align: center; + } + .logo { + font-size: 28px; + font-weight: 700; + margin-bottom: 8px; + letter-spacing: 1px; + } + .header-title { + font-size: 18px; + opacity: 0.95; + margin: 0; + } + .content { + padding: 35px; + } + .greeting { + font-size: 16px; + margin-bottom: 25px; + color: #374151; + } + .section { + margin-bottom: 30px; + } + .section-title { + font-size: 18px; + font-weight: 600; + color: #1f2937; + margin-bottom: 15px; + border-bottom: 2px solid #e5e7eb; + padding-bottom: 8px; + } + .info-grid { + background-color: #f9fafb; + border-radius: 8px; + padding: 20px; + border-left: 4px solid #3b82f6; + } + .info-row { + display: flex; + margin-bottom: 12px; + align-items: center; + } + .info-label { + font-weight: 600; + min-width: 140px; + color: #6b7280; + font-size: 14px; + } + .info-value { + color: #111827; + font-weight: 500; + } + .due-date { + color: #dc2626; + font-weight: 700; + background-color: #fef2f2; + padding: 2px 8px; + border-radius: 4px; + } + .message-box { + background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); + border-radius: 8px; + padding: 20px; + margin: 20px 0; + border: 1px solid #f59e0b; + } + .message-title { + font-weight: 600; + color: #92400e; + margin-bottom: 10px; + font-size: 16px; + } + .message-content { + color: #78350f; + white-space: pre-line; + line-height: 1.5; + } + .action-section { + text-align: center; + background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); + border-radius: 8px; + padding: 25px; + margin: 25px 0; + } + .action-title { + font-size: 18px; + font-weight: 600; + color: #1e40af; + margin-bottom: 10px; + } + .action-description { + color: #1e40af; + margin-bottom: 20px; + line-height: 1.5; + } + .cta-button { + display: inline-block; + background: linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%); + color: white; + padding: 14px 30px; + text-decoration: none; + border-radius: 8px; + font-weight: 600; + font-size: 16px; + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); + transition: all 0.3s ease; + } + .cta-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(59, 130, 246, 0.4); + } + .important-notes { + background-color: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + padding: 20px; + margin: 25px 0; + } + .important-title { + font-weight: 600; + color: #dc2626; + margin-bottom: 12px; + font-size: 16px; + } + .note-item { + color: #7f1d1d; + margin-bottom: 8px; + padding-left: 20px; + position: relative; + } + .note-item::before { + content: "•"; + color: #dc2626; + font-weight: 600; + position: absolute; + left: 0; + } + .contact-section { + background-color: #f8fafc; + border-radius: 8px; + padding: 20px; + margin: 25px 0; + border: 1px solid #e2e8f0; + } + .contact-title { + font-weight: 600; + color: #334155; + margin-bottom: 15px; + font-size: 16px; + } + .contact-info { + color: #475569; + line-height: 1.6; + } + .contact-email { + color: #3b82f6; + text-decoration: none; + font-weight: 500; + } + .footer { + background-color: #f1f5f9; + padding: 25px; + text-align: center; + color: #64748b; + font-size: 14px; + border-top: 1px solid #e2e8f0; + } + .footer-text { + margin-bottom: 8px; + } + .disclaimer { + font-size: 12px; + color: #94a3b8; + } + .bilingual { + margin-bottom: 4px; + } + .english { + color: #6b7280; + font-size: 14px; + } + </style> +</head> +<body> + <div class="email-container"> + <div class="header"> + <div class="logo">Oracle Corporation</div> + <h1 class="header-title"> + 협력업체 평가 자료 요청<br> + <span class="english">Vendor Evaluation Document Request</span> + </h1> + </div> + + <div class="content"> + <div class="greeting"> + <div class="bilingual">안녕하세요, <strong>{{companyName}}</strong> 담당자님</div> + <div class="english">Dear <strong>{{companyName}}</strong> Representative,</div> + </div> + + <div class="section"> + <h2 class="section-title"> + 평가 정보 | Evaluation Information + </h2> + <div class="info-grid"> + <div class="info-row"> + <span class="info-label">평가 연도 | Year:</span> + <span class="info-value">{{evaluationYear}}</span> + </div> + {{#if evaluationRound}} + <div class="info-row"> + <span class="info-label">평가 차수 | Round:</span> + <span class="info-value">{{evaluationRound}}</span> + </div> + {{/if}} + <div class="info-row"> + <span class="info-label">요청일 | Request Date:</span> + <span class="info-value">{{requestDate}}</span> + </div> + {{#if dueDate}} + <div class="info-row"> + <span class="info-label">제출 마감일 | Due Date:</span> + <span class="info-value due-date">{{dueDate}}</span> + </div> + {{/if}} + </div> + </div> + + {{#if reviewComments}} + <div class="section"> + <div class="message-box"> + <div class="message-title"> + 추가 안내사항 | Additional Message + </div> + <div class="message-content">{{reviewComments}}</div> + </div> + </div> + {{/if}} + + <div class="section"> + <div class="action-section"> + <h3 class="action-title"> + 필요한 조치 | Action Required + </h3> + <p class="action-description"> + 아래 버튼을 클릭하여 협력업체 포털에 접속하시고, 요청된 평가 자료를 제출해 주시기 바랍니다.<br> + <span class="english">Please click the button below to access the vendor portal and submit the requested evaluation documents.</span> + </p> + <a href="{{accessUrl}}" class="cta-button"> + 협력업체 포털 접속 | Access Vendor Portal + </a> + </div> + </div> + + <div class="section"> + <div class="important-notes"> + <div class="important-title"> + 중요 안내 | Important Notice + </div> + <div class="note-item"> + 반드시 마감일까지 모든 자료를 제출해 주세요<br> + <span class="english">Please ensure all documents are submitted by the deadline</span> + </div> + <div class="note-item"> + 모든 평가 항목을 빠짐없이 작성해 주세요<br> + <span class="english">Please complete all evaluation sections thoroughly</span> + </div> + <div class="note-item"> + 문의사항이 있으시면 아래 연락처로 연락해 주세요<br> + <span class="english">If you have any questions, please contact us using the information below</span> + </div> + </div> + </div> + + <div class="section"> + <div class="contact-section"> + <h4 class="contact-title"> + 문의 연락처 | Contact Information + </h4> + <div class="contact-info"> + <strong>담당자 | Manager:</strong> {{managerName}}<br> + <strong>이메일 | Email:</strong> <a href="mailto:{{supportEmail}}" class="contact-email">{{supportEmail}}</a><br> + <strong>업무시간 | Business Hours:</strong> {{businessHours}} + </div> + </div> + </div> + </div> + + <div class="footer"> + <div class="footer-text"> + 본 메일은 Oracle Corporation 협력업체 평가 시스템에서 자동 발송되었습니다.<br> + <span class="english">This email was automatically sent from the Oracle Corporation Vendor Evaluation System.</span> + </div> + <div class="disclaimer"> + 본 메일에 회신하지 마시고, 문의사항은 위의 연락처를 이용해 주세요.<br> + Please do not reply to this email. For inquiries, please use the contact information above. + </div> + </div> + </div> +</body> +</html>
\ No newline at end of file diff --git a/lib/sedp/get-form-tags.ts b/lib/sedp/get-form-tags.ts index 4e819414..f307d54a 100644 --- a/lib/sedp/get-form-tags.ts +++ b/lib/sedp/get-form-tags.ts @@ -388,8 +388,9 @@ export async function importTagsFromSEDP( // tagClass 조회 (CLS_ID -> label) let tagClassLabel = tagEntry.CLS_ID; // 기본값 + let tagClassId = null; // 기본값 if (tagEntry.CLS_ID) { - const tagClassRecord = await tx.select({ label: tagClasses.label }) + const tagClassRecord = await tx.select({ id:tagClasses,id, label: tagClasses.label }) .from(tagClasses) .where(and( eq(tagClasses.code, tagEntry.CLS_ID), @@ -399,6 +400,7 @@ export async function importTagsFromSEDP( if (tagClassRecord && tagClassRecord.length > 0) { tagClassLabel = tagClassRecord[0].label; + tagClassId = tagClassRecord[0].id; } } @@ -416,6 +418,7 @@ export async function importTagsFromSEDP( tagNo: tagEntry.TAG_NO, tagType: tagTypeDescription, class: tagClassLabel, + tagClassId: tagClassId, description: tagEntry.TAG_DESC || null, createdAt: new Date(), updatedAt: new Date() diff --git a/lib/sedp/get-tags.ts b/lib/sedp/get-tags.ts index 00916eb2..cb549d8c 100644 --- a/lib/sedp/get-tags.ts +++ b/lib/sedp/get-tags.ts @@ -369,6 +369,7 @@ export async function importTagsFromSEDP( formId: formId, tagNo: entry.TAG_NO, tagType: tagType?.description || entry.TAG_TYPE_ID, + tagClassId: tagClass?.id, class: tagClass?.label || entry.CLS_ID, description: entry.TAG_DESC }).onConflictDoUpdate({ diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts index 87de4645..c3c88f07 100644 --- a/lib/sedp/sync-form.ts +++ b/lib/sedp/sync-form.ts @@ -984,7 +984,7 @@ export async function saveFormMappingsAndMetas( if (!attribute) continue; const tmplMeta = templateAttrMap.get(attId); - const isShi = mapAtt.INOUT === "OUT"; + const isShi = mapAtt.INOUT === "IN"; let uomSymbol: string | undefined; let uomId: string | undefined; if (legacy?.LNK_ATT) { @@ -996,7 +996,7 @@ export async function saveFormMappingsAndMetas( key: attId, label: attribute.DESC as string, type: (attribute.VAL_TYPE === "LIST" || attribute.VAL_TYPE === "DYNAMICLIST") ? "LIST" : (attribute.VAL_TYPE || "STRING"), - shi: isShi, + shi: !isShi, hidden: tmplMeta?.hidden ?? false, seq: tmplMeta?.seq ?? 0, head: tmplMeta?.head ?? "", diff --git a/lib/sedp/sync-object-class.ts b/lib/sedp/sync-object-class.ts index 5fd3ebff..1a325407 100644 --- a/lib/sedp/sync-object-class.ts +++ b/lib/sedp/sync-object-class.ts @@ -6,6 +6,12 @@ import { getSEDPToken } from "./sedp-token"; // 환경 변수 const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; +// 서브클래스 타입 정의 +interface SubclassInfo { + id: string; + desc: string; +} + // ObjectClass 인터페이스 정의 interface ObjectClass { PROJ_NO: string; @@ -55,6 +61,60 @@ interface LinkAttribute { SEQ: number; } +interface SubClassCodeValue { + PRNT_VALUE: string; + VALUE: string; + DESC: string; + REMARK: string; + USE_YN: boolean; + SEQ: number; + ATTRIBUTES: Array<{ + ATT_ID: string; + VALUE: string; + }>; +} + +/** + * 프로젝트별 CodeList(클래스 코드 값) 조회 + */ +export async function getCodeListsByID(projectCode: string): Promise<SubClassCodeValue[]> { + try { + // 1) 토큰(API 키) 발급 + const apiKey = await getSEDPToken(); + + // 2) API 호출 + const response = await fetch(`${SEDP_API_BASE_URL}/CodeList/GetByID`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + accept: '*/*', + ApiKey: apiKey, + ProjectNo: projectCode, + }, + body: JSON.stringify({ + ProjectNo: projectCode, + CL_ID: 'EVCP_TAG_FUNC', + ContainDeleted: false, + }), + }); + + if (!response.ok) { + // 네트워크·인증 오류 등 + throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`); + } + + // 3) 응답 파싱 + const { VALUES = [] } = (await response.json()) as { VALUES?: SubClassCodeValue[] }; + + return VALUES; // 정상 반환 + } catch (err) { + // 4) 파싱 오류·기타 예외 + console.error(`프로젝트 ${projectCode}의 코드 리스트 가져오기 실패:`, err); + + return []; + } +} + // 태그 클래스 속성 저장 함수 async function saveTagClassAttributes( tagClassId: number, @@ -366,197 +426,237 @@ async function getAllTagTypes(projectCode: string, token: string): Promise<TagTy } } -// LNK_ATT 속성 처리가 포함된 오브젝트 클래스 저장 함수 +// 수정된 함수: ID와 DESC을 함께 반환 +function findSubclasses(parentCode: string, allClasses: ObjectClass[]): SubclassInfo[] { + return allClasses + .filter(cls => cls.PRT_CLS_ID === parentCode) + .map(cls => ({ + id: cls.CLS_ID, + desc: cls.DESC + })); +} + +// 서브클래스별 리마크 가져오기 (수정됨) +async function getSubclassRemarks( + subclasses: SubclassInfo[], + projectCode: string +): Promise<Record<string, string>> { + try { + if (subclasses.length === 0) { + return {}; + } + + // getCodeListsByID로 코드 리스트 가져오기 + const codeValues = await getCodeListsByID(projectCode); + + if (!Array.isArray(codeValues)) { + console.log(`프로젝트 ${projectCode}의 코드 리스트가 배열이 아닙니다.`); + return {}; + } + + // 서브클래스별 리마크 매핑 + const remarkMap: Record<string, string> = {}; + + for (const subclass of subclasses) { + // VALUE가 서브클래스 ID와 일치하는 항목 찾기 + const matchedValue = codeValues.find(value => value.VALUE === subclass.id); + if (matchedValue && matchedValue.REMARK) { + remarkMap[subclass.id] = matchedValue.REMARK; + } else { + // REMARK가 없는 경우 빈 문자열 또는 기본값 + remarkMap[subclass.id] = ''; + } + } + + return remarkMap; + } catch (error) { + console.error(`서브클래스 리마크 가져오기 실패 (프로젝트: ${projectCode}):`, error); + return {}; + } +} + +// LNK_ATT 속성 처리가 포함된 오브젝트 클래스 저장 함수 (수정됨) async function saveObjectClassesToDatabase( projectId: number, classes: ObjectClass[], projectCode: string, token: string, - skipTagTypeSync: boolean = false // 태그 타입 동기화를 건너뛸지 여부 + skipTagTypeSync: boolean = false ): Promise<number> { try { - // null이 아닌 TAG_TYPE_ID만 필터링 - const validClasses = classes.filter(cls => cls.TAG_TYPE_ID !== null && cls.TAG_TYPE_ID !== "") ; - - if (validClasses.length === 0) { - console.log(`프로젝트 ID ${projectId}에 저장할 유효한 오브젝트 클래스가 없습니다.`); - return 0; - } - - // 모든 태그 타입 ID 목록 추출 - const tagTypeCodes = validClasses.map(cls => cls.TAG_TYPE_ID!); - - // skipTagTypeSync가 true인 경우 태그 타입 동기화 단계 건너뜀 - if (!skipTagTypeSync) { - // 태그 타입이 없는 경우를 대비해 태그 타입 정보 먼저 가져와서 저장 - console.log(`프로젝트 ID ${projectId}의 태그 타입 동기화 시작...`); + // null이 아닌 TAG_TYPE_ID만 필터링 + const validClasses = classes.filter(cls => cls.TAG_TYPE_ID !== null && cls.TAG_TYPE_ID !== ""); - try { - // 프로젝트의 모든 태그 타입 가져오기 - const allTagTypes = await getAllTagTypes(projectCode, token); - - // 태그 타입 저장 - await saveTagTypesToDatabase(allTagTypes, projectCode); - } catch (error) { - console.error(`프로젝트 ${projectCode}의 태그 타입 동기화 실패:`, error); - // 에러가 발생해도 계속 진행 + if (validClasses.length === 0) { + console.log(`프로젝트 ID ${projectId}에 저장할 유효한 오브젝트 클래스가 없습니다.`); + return 0; } - console.log(`프로젝트 ID ${projectId}의 태그 타입 동기화 완료`); - } - - // 존재하는 태그 타입 확인 - const existingTagTypeCodes = await verifyTagTypes(projectId, tagTypeCodes); - - // 태그 타입이 존재하는 오브젝트 클래스만 필터링 - const classesToSave = validClasses.filter(cls => - cls.TAG_TYPE_ID !== null && existingTagTypeCodes.has(cls.TAG_TYPE_ID) - ); - - if (classesToSave.length === 0) { - console.log(`프로젝트 ID ${projectId}에 저장할 유효한 오브젝트 클래스가 없습니다 (태그 타입 존재하지 않음).`); - return 0; - } - - // 현재 프로젝트의 오브젝트 클래스 코드 가져오기 - const existingClasses = await db.select() - .from(tagClasses) - .where(eq(tagClasses.projectId, projectId)); - - // 코드 기준으로 맵 생성 - const existingClassMap = new Map( - existingClasses.map(cls => [cls.code, cls]) - ); - - // 새로 추가할 항목 - const toInsert = []; - - // 업데이트할 항목 - const toUpdate = []; - - // API에 있는 코드 목록 - const apiClassCodes = new Set(classesToSave.map(cls => cls.CLS_ID)); - - // 삭제할 코드 목록 - const codesToDelete = existingClasses - .map(cls => cls.code) - .filter(code => !apiClassCodes.has(code)); - - // 클래스 데이터 처리 - for (const cls of classesToSave) { - // 데이터베이스 레코드 준비 - const record = { - code: cls.CLS_ID, - projectId: projectId, - label: cls.DESC, - tagTypeCode: cls.TAG_TYPE_ID!, - updatedAt: new Date() - }; + // 태그 타입 동기화 (기존 로직 유지) + 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}의 태그 타입 동기화 완료`); + } - // 이미 존재하는 코드인지 확인 - if (existingClassMap.has(cls.CLS_ID)) { - // 업데이트 항목에 추가 - toUpdate.push(record); - } else { - // 새로 추가할 항목에 추가 (createdAt 필드 추가) - toInsert.push({ - ...record, - createdAt: new Date() - }); + // 존재하는 태그 타입 확인 + const tagTypeCodes = validClasses.map(cls => cls.TAG_TYPE_ID!); + const existingTagTypeCodes = await verifyTagTypes(projectId, tagTypeCodes); + + // 태그 타입이 존재하는 오브젝트 클래스만 필터링 + const classesToSave = validClasses.filter(cls => + cls.TAG_TYPE_ID !== null && existingTagTypeCodes.has(cls.TAG_TYPE_ID) + ); + + if (classesToSave.length === 0) { + console.log(`프로젝트 ID ${projectId}에 저장할 유효한 오브젝트 클래스가 없습니다.`); + return 0; } - } - - // 트랜잭션 실행 - let totalChanged = 0; - - // 1. 새 항목 삽입 및 속성 처리 - if (toInsert.length > 0) { - // returning을 사용하여 삽입된 레코드의 ID와 code를 가져옴 - const insertedClasses = await db.insert(tagClasses) - .values(toInsert) - .returning({ id: tagClasses.id, code: tagClasses.code }); - - totalChanged += toInsert.length; - console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 오브젝트 클래스 추가 완료`); - // 새로 삽입된 각 클래스의 LNK_ATT 속성 처리 - for (const insertedClass of insertedClasses) { - const originalClass = classesToSave.find(cls => cls.CLS_ID === insertedClass.code); - if (originalClass && originalClass.LNK_ATT && originalClass.LNK_ATT.length > 0) { - try { - await saveTagClassAttributes(insertedClass.id, originalClass.LNK_ATT); - } catch (error) { - console.error(`태그 클래스 ${insertedClass.code}의 속성 저장 실패:`, error); - // 속성 저장 실패해도 계속 진행 + // 현재 프로젝트의 모든 오브젝트 클래스 가져오기 + const existingClasses = await db.select() + .from(tagClasses) + .where(eq(tagClasses.projectId, projectId)); + + // 코드 기준으로 맵 생성 + const existingClassMap = new Map( + existingClasses.map(cls => [cls.code, cls]) + ); + + // 새로 추가할 항목과 업데이트할 항목 분리 + const toInsert = []; + const toUpdate = []; + + // API에 있는 코드 목록 + const apiClassCodes = new Set(classesToSave.map(cls => cls.CLS_ID)); + + // 삭제할 코드 목록 + const codesToDelete = existingClasses + .map(cls => cls.code) + .filter(code => !apiClassCodes.has(code)); + + // 각 클래스별로 서브클래스와 리마크 처리 + for (const cls of classesToSave) { + // 서브클래스 찾기 (이제 {id, desc} 형태로 반환) + const subclasses = findSubclasses(cls.CLS_ID, classes); + + // 서브클래스별 리마크 가져오기 + const subclassRemark = await getSubclassRemarks(subclasses, projectCode); + + const record = { + code: cls.CLS_ID, + projectId: projectId, + label: cls.DESC, + tagTypeCode: cls.TAG_TYPE_ID!, + subclasses: subclasses, // 이제 {id, desc}[] 형태 + subclassRemark: subclassRemark, + updatedAt: new Date() + }; + + if (existingClassMap.has(cls.CLS_ID)) { + toUpdate.push(record); + } else { + toInsert.push({ + ...record, + createdAt: new Date() + }); } - } } - } - - // 2. 기존 항목 업데이트 및 속성 처리 - for (const item of toUpdate) { - await db.update(tagClasses) - .set({ - label: item.label, - tagTypeCode: item.tagTypeCode, - updatedAt: item.updatedAt - }) - .where( - and( - eq(tagClasses.code, item.code), - eq(tagClasses.projectId, item.projectId) - ) - ); - // 업데이트된 클래스의 ID 조회 - const updatedClass = await db.select({ id: tagClasses.id }) - .from(tagClasses) - .where( - and( - eq(tagClasses.code, item.code), - eq(tagClasses.projectId, item.projectId) - ) - ) - .limit(1); + let totalChanged = 0; - if (updatedClass.length > 0) { - const originalClass = classesToSave.find(cls => cls.CLS_ID === item.code); - if (originalClass && originalClass.LNK_ATT) { - try { - await saveTagClassAttributes(updatedClass[0].id, originalClass.LNK_ATT); - } catch (error) { - console.error(`태그 클래스 ${item.code}의 속성 업데이트 실패:`, error); - // 속성 업데이트 실패해도 계속 진행 + // 새 항목 삽입 + if (toInsert.length > 0) { + const insertedClasses = await db.insert(tagClasses) + .values(toInsert) + .returning({ id: tagClasses.id, code: tagClasses.code }); + + totalChanged += toInsert.length; + console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 오브젝트 클래스 추가 완료`); + + // 새로 삽입된 각 클래스의 LNK_ATT 속성 처리 + for (const insertedClass of insertedClasses) { + const originalClass = classesToSave.find(cls => cls.CLS_ID === insertedClass.code); + if (originalClass && originalClass.LNK_ATT && originalClass.LNK_ATT.length > 0) { + try { + await saveTagClassAttributes(insertedClass.id, originalClass.LNK_ATT); + } catch (error) { + console.error(`태그 클래스 ${insertedClass.code}의 속성 저장 실패:`, error); + } + } } - } } - totalChanged += 1; - } - - if (toUpdate.length > 0) { - console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 오브젝트 클래스 업데이트 완료`); - } - - // 3. 더 이상 존재하지 않는 항목 삭제 (CASCADE로 속성도 자동 삭제됨) - if (codesToDelete.length > 0) { - for (const code of codesToDelete) { - await db.delete(tagClasses) - .where( - and( - eq(tagClasses.code, code), - eq(tagClasses.projectId, projectId) - ) - ); + // 기존 항목 업데이트 + for (const item of toUpdate) { + await db.update(tagClasses) + .set({ + label: item.label, + tagTypeCode: item.tagTypeCode, + subclasses: item.subclasses, + subclassRemark: item.subclassRemark, + updatedAt: item.updatedAt + }) + .where( + and( + eq(tagClasses.code, item.code), + eq(tagClasses.projectId, item.projectId) + ) + ); + + // 업데이트된 클래스의 속성 처리 + const updatedClass = await db.select({ id: tagClasses.id }) + .from(tagClasses) + .where( + and( + eq(tagClasses.code, item.code), + eq(tagClasses.projectId, item.projectId) + ) + ) + .limit(1); + + if (updatedClass.length > 0) { + const originalClass = classesToSave.find(cls => cls.CLS_ID === item.code); + if (originalClass && originalClass.LNK_ATT) { + try { + await saveTagClassAttributes(updatedClass[0].id, originalClass.LNK_ATT); + } catch (error) { + console.error(`태그 클래스 ${item.code}의 속성 업데이트 실패:`, error); + } + } + } + + totalChanged += 1; } - console.log(`프로젝트 ID ${projectId}에서 ${codesToDelete.length}개의 오브젝트 클래스 삭제 완료`); - totalChanged += codesToDelete.length; - } - - return totalChanged; + + if (toUpdate.length > 0) { + console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 오브젝트 클래스 업데이트 완료`); + } + + // 더 이상 존재하지 않는 항목 삭제 + if (codesToDelete.length > 0) { + for (const code of codesToDelete) { + await db.delete(tagClasses) + .where( + and( + eq(tagClasses.code, code), + eq(tagClasses.projectId, projectId) + ) + ); + } + console.log(`프로젝트 ID ${projectId}에서 ${codesToDelete.length}개의 오브젝트 클래스 삭제 완료`); + totalChanged += codesToDelete.length; + } + + return totalChanged; } catch (error) { - console.error(`오브젝트 클래스 저장 실패 (프로젝트 ID: ${projectId}):`, error); - throw error; + console.error(`오브젝트 클래스 저장 실패 (프로젝트 ID: ${projectId}):`, error); + throw error; } } @@ -717,6 +817,8 @@ export async function getProjectTagClassesWithAttributes(projectId: number) { code: tagClasses.code, label: tagClasses.label, tagTypeCode: tagClasses.tagTypeCode, + subclasses: tagClasses.subclasses, + subclassRemark: tagClasses.subclassRemark, createdAt: tagClasses.createdAt, updatedAt: tagClasses.updatedAt, // 속성 정보 @@ -741,6 +843,8 @@ export async function getProjectTagClassesWithAttributes(projectId: number) { code: row.code, label: row.label, tagTypeCode: row.tagTypeCode, + subclasses: row.subclasses, // 이제 {id, desc}[] 형태 + subclassRemark: row.subclassRemark, createdAt: row.createdAt, updatedAt: row.updatedAt, attributes: [] diff --git a/lib/tags/service.ts b/lib/tags/service.ts index e65ab65b..a1dff137 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -12,6 +12,8 @@ import { countTags, insertTag, selectTags } from "./repository"; import { getErrorMessage } from "../handle-error"; import { getFormMappingsByTagType } from './form-mapping-service'; import { contractItems, contracts } from "@/db/schema/contract"; +import { getCodeListsByID } from "../sedp/sync-object-class"; +import { projects } from "@/db/schema"; // 폼 결과를 위한 인터페이스 정의 @@ -1261,52 +1263,69 @@ export interface ClassOption { * Class 옵션 목록을 가져오는 함수 * 이제 각 클래스는 연결된 tagTypeCode와 tagTypeDescription을 포함 */ -export async function getClassOptions(packageId?: number) { - if (!packageId) { - throw new Error("패키지 ID가 필요합니다"); - } +export async function getClassOptions(selectedPackageId: number): Promise<UpdatedClassOption[]> { + try { + // 1. 먼저 contractItems에서 projectId 조회 + const packageInfo = await db + .select({ + projectId: contracts.projectId + }) + .from(contractItems) + .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) + .where(eq(contractItems.id, selectedPackageId)) + .limit(1); - // 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 === 0) { + throw new Error(`Contract item with ID ${selectedPackageId} not found`); + } - if (!packageInfo.length) { - throw new Error("패키지를 찾을 수 없거나 연결된 프로젝트가 없습니다"); - } + const projectId = packageInfo[0].projectId; - const projectId = packageInfo[0].projectId; + // 2. 태그 클래스들을 서브클래스 정보와 함께 조회 + const tagClassesWithSubclasses = await db + .select({ + id: tagClasses.id, + code: tagClasses.code, + label: tagClasses.label, + tagTypeCode: tagClasses.tagTypeCode, + subclasses: tagClasses.subclasses, + subclassRemark: tagClasses.subclassRemark, + }) + .from(tagClasses) + .where(eq(tagClasses.projectId, projectId)) + .orderBy(tagClasses.code); + // 3. 태그 타입 정보도 함께 조회 (description을 위해) + const tagTypesMap = new Map(); + const tagTypesList = await db + .select({ + code: tagTypes.code, + description: tagTypes.description, + }) + .from(tagTypes) + .where(eq(tagTypes.projectId, projectId)); - // Now get the tag classes filtered by projectId - const rows = await db - .select({ - id: tagClasses.id, - code: tagClasses.code, - label: tagClasses.label, - tagTypeCode: tagClasses.tagTypeCode, - tagTypeDescription: tagTypes.description, - }) - .from(tagClasses) - .leftJoin(tagTypes, and( - eq(tagTypes.code, tagClasses.tagTypeCode), - eq(tagTypes.projectId, tagClasses.projectId) - )) - .where(eq(tagClasses.projectId, projectId)); - - console.log(rows) - - return rows.map((row) => ({ - code: row.code, - label: row.label, - tagTypeCode: row.tagTypeCode, - tagTypeDescription: row.tagTypeDescription ?? "", - })); + tagTypesList.forEach(tagType => { + tagTypesMap.set(tagType.code, tagType.description); + }); + + // 4. 클래스 옵션으로 변환 + const classOptions: UpdatedClassOption[] = tagClassesWithSubclasses.map(cls => ({ + value: cls.code, + label: cls.label, + code: cls.code, + description: cls.label, + tagTypeCode: cls.tagTypeCode, + tagTypeDescription: tagTypesMap.get(cls.tagTypeCode) || cls.tagTypeCode, + subclasses: cls.subclasses || [], + subclassRemark: cls.subclassRemark || {}, + })); + + return classOptions; + } catch (error) { + console.error("Error fetching class options with subclasses:", error); + throw new Error("Failed to fetch class options"); + } } interface SubFieldDef { name: string @@ -1317,7 +1336,12 @@ interface SubFieldDef { delimiter: string | null } -export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackageId: number) { +export async function getSubfieldsByTagType( + tagTypeCode: string, + selectedPackageId: number, + subclassRemark: string = "", + subclass: string = "", +) { try { // 1. 먼저 contractItems에서 projectId 조회 const packageInfo = await db @@ -1352,9 +1376,10 @@ export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackage for (const sf of rows) { // projectId가 필요한 경우 getSubfieldType과 getSubfieldOptions 함수에도 전달 const subfieldType = await getSubfieldType(sf.attributesId, projectId); + const subclassMatched =subclassRemark.includes(sf.attributesId ) ? subclass: null const subfieldOptions = subfieldType === "select" - ? await getSubfieldOptions(sf.attributesId, projectId) + ? await getSubfieldOptions(sf.attributesId, projectId, subclassMatched) // subclassRemark 파라미터 추가 : []; formattedSubFields.push({ @@ -1375,6 +1400,7 @@ export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackage } + async function getSubfieldType(attributesId: string, projectId: number): Promise<"select" | "text"> { const optRows = await db .select() @@ -1403,9 +1429,58 @@ export interface SubfieldOption { /** * SubField의 옵션 목록을 가져오는 보조 함수 */ -async function getSubfieldOptions(attributesId: string, projectId: number): Promise<SubfieldOption[]> { +async function getSubfieldOptions( + attributesId: string, + projectId: number, + subclass: string = "" +): Promise<SubfieldOption[]> { try { - const rows = await db + // 1. subclassRemark가 있는 경우 API에서 코드 리스트 가져와서 필터링 + if (subclass && subclass.trim() !== "") { + // 프로젝트 코드를 projectId로부터 조회 + const projectInfo = await db + .select({ + code: projects.code + }) + .from(projects) + .where(eq(projects.id, projectId)) + .limit(1); + + if (projectInfo.length === 0) { + throw new Error(`Project with ID ${projectId} not found`); + } + + const projectCode = projectInfo[0].code; + + // API에서 코드 리스트 가져오기 + const codeListValues = await getCodeListsByID(projectCode); + + // 서브클래스 리마크 값들을 분리 (쉼표, 공백 등으로 구분) + const remarkValues = subclass + .split(/[,\s]+/) // 쉼표나 공백으로 분리 + .map(val => val.trim()) + .filter(val => val.length > 0); + + if (remarkValues.length > 0) { + // REMARK 필드가 remarkValues 중 하나를 포함하고 있는 항목들 필터링 + const filteredCodeValues = codeListValues.filter(codeValue => + remarkValues.some(remarkValue => + // 대소문자 구분 없이 포함 여부 확인 + codeValue.VALUE.toLowerCase().includes(remarkValue.toLowerCase()) || + remarkValue.toLowerCase().includes(codeValue.VALUE.toLowerCase()) + ) + ); + + // 필터링된 결과를 PRNT_VALUE -> value, DESC -> label로 변환 + return filteredCodeValues.map((codeValue) => ({ + value: codeValue.PRNT_VALUE, + label: codeValue.DESC + })); + } + } + + // 2. subclassRemark가 없는 경우 기존 방식으로 DB에서 조회 + const allOptions = await db .select({ code: tagSubfieldOptions.code, label: tagSubfieldOptions.label @@ -1416,18 +1491,24 @@ async function getSubfieldOptions(attributesId: string, projectId: number): Prom eq(tagSubfieldOptions.attributesId, attributesId), eq(tagSubfieldOptions.projectId, projectId), ) - ) + ); - return rows.map((row) => ({ + return allOptions.map((row) => ({ value: row.code, label: row.label - })) + })); } catch (error) { - console.error(`Error fetching options for attribute ${attributesId}:`, error) - return [] + console.error(`Error fetching filtered options for attribute ${attributesId}:`, error); + return []; } } +export interface UpdatedClassOption extends ClassOption { + tagTypeCode: string + tagTypeDescription?: string + subclasses: {id: string, desc: string}[] + subclassRemark: Record<string, string> +} /** * Tag Type 목록을 가져오는 함수 diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx index cb71896c..10167c62 100644 --- a/lib/tags/table/add-tag-dialog.tsx +++ b/lib/tags/table/add-tag-dialog.tsx @@ -62,10 +62,11 @@ import { TagTypeOption, } from "@/lib/tags/service" -// Updated to support multiple rows +// Updated to support multiple rows and subclass interface MultiTagFormValues { class: string; tagType: string; + subclass: string; // 새로 추가된 서브클래스 필드 rows: Array<{ [key: string]: string; tagNo: string; @@ -83,10 +84,15 @@ interface SubFieldDef { delimiter?: string } -// 클래스 옵션 인터페이스 +// 업데이트된 클래스 옵션 인터페이스 (서브클래스 정보 포함) interface UpdatedClassOption extends ClassOption { tagTypeCode: string tagTypeDescription?: string + subclasses: { + id: string; + desc: string; + }[] // 서브클래스 배열 추가 + subclassRemark: Record<string, string> // 서브클래스 리마크 추가 } interface AddTagDialogProps { @@ -99,6 +105,8 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { const [open, setOpen] = React.useState(false) const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([]) const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null) + const [selectedClassOption, setSelectedClassOption] = React.useState<UpdatedClassOption | null>(null) + const [selectedSubclass, setSelectedSubclass] = React.useState<string>("") const [subFields, setSubFields] = React.useState<SubFieldDef[]>([]) const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([]) const [classSearchTerm, setClassSearchTerm] = React.useState("") @@ -112,31 +120,29 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) { const fieldIdsRef = React.useRef<Record<string, string>>({}) const classOptionIdsRef = React.useRef<Record<string, string>>({}) - console.log(selectedPackageId, "tag") // --------------- - // Load Class Options + // Load Class Options (서브클래스 정보 포함) // --------------- -// 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) + React.useEffect(() => { + const loadClassOptions = async () => { + setIsLoadingClasses(true) + try { + // getClassOptions 함수가 서브클래스 정보도 포함하도록 수정되었다고 가정 + const result = await getClassOptions(selectedPackageId) + setClassOptions(result) + } catch (err) { + toast.error("클래스 옵션을 불러오는데 실패했습니다.") + } finally { + setIsLoadingClasses(false) + } } - } - if (open) { - loadClassOptions() - } -}, [open, selectedPackageId]) // Add selectedPackageId to the dependency array + if (open) { + loadClassOptions() + } + }, [open, selectedPackageId]) // --------------- // react-hook-form with fieldArray support for multiple rows @@ -145,6 +151,7 @@ React.useEffect(() => { defaultValues: { tagType: "", class: "", + subclass: "", // 서브클래스 필드 추가 rows: [{ tagNo: "", description: "" @@ -158,12 +165,13 @@ React.useEffect(() => { }) // --------------- - // Load subfields by TagType code + // 서브클래스별로 필터링된 서브필드 로드 // --------------- - async function loadSubFieldsByTagTypeCode(tagTypeCode: string) { + async function loadFilteredSubFieldsByTagTypeCode(tagTypeCode: string, subclassRemark: string, subclass: string) { setIsLoadingSubFields(true) try { - const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId) + // 수정된 getSubfieldsByTagType 함수 호출 (subclassRemark 파라미터 추가) + const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId, subclassRemark ,subclass ) const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({ name: field.name, label: field.label, @@ -202,6 +210,10 @@ React.useEffect(() => { // --------------- async function handleSelectClass(classOption: UpdatedClassOption) { form.setValue("class", classOption.label) + form.setValue("subclass", "") // 서브클래스 초기화 + setSelectedClassOption(classOption) + setSelectedSubclass("") + if (classOption.tagTypeCode) { setSelectedTagTypeCode(classOption.tagTypeCode) // If you have tagTypeList, you can find the label @@ -211,49 +223,76 @@ React.useEffect(() => { } else if (classOption.tagTypeDescription) { form.setValue("tagType", classOption.tagTypeDescription) } - await loadSubFieldsByTagTypeCode(classOption.tagTypeCode) + + // 서브클래스가 있으면 서브필드 로딩을 하지 않고 대기 + if (classOption.subclasses && classOption.subclasses.length > 0) { + setSubFields([]) // 서브클래스 선택을 기다림 + } else { + // 서브클래스가 없으면 바로 서브필드 로딩 + await loadFilteredSubFieldsByTagTypeCode(classOption.tagTypeCode, "","") + } } } // --------------- + // Handle subclass selection + // --------------- + async function handleSelectSubclass(subclassCode: string) { + if (!selectedClassOption || !selectedTagTypeCode) return + + setSelectedSubclass(subclassCode) + form.setValue("subclass", subclassCode) + + // 선택된 서브클래스의 리마크 값 가져오기 + const subclassRemarkValue = selectedClassOption.subclassRemark[subclassCode] || "" + + // 리마크 값으로 필터링된 서브필드 로드 + await loadFilteredSubFieldsByTagTypeCode(selectedTagTypeCode, subclassRemarkValue, subclassCode) + } + + // --------------- // Build TagNo from subfields automatically for each row // --------------- React.useEffect(() => { if (subFields.length === 0) { return; } - + const subscription = form.watch((value) => { if (!value.rows || subFields.length === 0) { return; } - + const rows = [...value.rows]; rows.forEach((row, rowIndex) => { if (!row) return; - + let combined = ""; subFields.forEach((sf, idx) => { const fieldValue = row[sf.name] || ""; - combined += fieldValue; - if (fieldValue && idx < subFields.length - 1 && sf.delimiter) { + + // delimiter를 앞에 붙이기 (첫 번째 필드가 아니고, 현재 필드에 값이 있고, delimiter가 있는 경우) + if (idx > 0 && fieldValue && sf.delimiter) { combined += sf.delimiter; } + + combined += fieldValue; }); - + const currentTagNo = form.getValues(`rows.${rowIndex}.tagNo`); if (currentTagNo !== combined) { form.setValue(`rows.${rowIndex}.tagNo`, combined, { - shouldDirty: true, // Changed from false to true - shouldTouch: true, // Changed from false to true - shouldValidate: true, // Changed from false to true + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, }); } }); }); - + return () => subscription.unsubscribe(); }, [subFields, form]); + // --------------- // Check if tag numbers are valid // --------------- @@ -263,9 +302,10 @@ React.useEffect(() => { const tagNo = row.tagNo; return tagNo && tagNo.trim() !== "" && !tagNo.includes("??"); }); - }, [form.watch()]); // Watch the entire form to catch all changes + }, [form.watch()]); + // --------------- - // Submit handler for multiple tags + // Submit handler for multiple tags (서브클래스 정보 포함) // --------------- async function onSubmit(data: MultiTagFormValues) { if (!selectedPackageId) { @@ -280,10 +320,11 @@ React.useEffect(() => { // Process each row for (const row of data.rows) { - // Create tag data from the row and shared class/tagType + // Create tag data from the row and shared class/tagType/subclass const tagData: CreateTagSchema = { tagType: data.tagType, class: data.class, + // subclass: data.subclass, // 서브클래스 정보 추가 tagNo: row.tagNo, description: row.description, ...Object.fromEntries( @@ -316,7 +357,6 @@ React.useEffect(() => { if (failedTags.length > 0) { console.log("Failed tags:", failedTags); - toast.error(`${failedTags.length}개의 태그 생성에 실패했습니다.`); } @@ -339,11 +379,10 @@ React.useEffect(() => { // Add a new row // --------------- function addRow() { - // Create a properly typed row with index signature to allow dynamic properties const newRow: { tagNo: string; description: string; - [key: string]: string; // This allows any string key with string values + [key: string]: string; } = { tagNo: "", description: "" @@ -355,8 +394,6 @@ React.useEffect(() => { }); append(newRow); - - // Force form validation after row is added setTimeout(() => form.trigger(), 0); } @@ -365,18 +402,14 @@ React.useEffect(() => { // --------------- function duplicateRow(index: number) { const rowToDuplicate = form.getValues(`rows.${index}`); - // Use proper typing with index signature const newRow: { tagNo: string; description: string; [key: string]: string; } = { ...rowToDuplicate }; - // Clear the tagNo field as it will be auto-generated newRow.tagNo = ""; append(newRow); - - // Force form validation after row is duplicated setTimeout(() => form.trigger(), 0); } @@ -400,7 +433,7 @@ React.useEffect(() => { ) return ( - <FormItem className="w-1/2"> + <FormItem className="w-1/3"> <FormLabel>Class</FormLabel> <FormControl> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> @@ -482,6 +515,45 @@ React.useEffect(() => { } // --------------- + // Render Subclass field (새로 추가) + // --------------- + function renderSubclassField(field: any) { + const hasSubclasses = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 + + if (!hasSubclasses) { + return null + } + + return ( + <FormItem className="w-1/3"> + <FormLabel>Subclass</FormLabel> + <FormControl> + <Select + value={field.value || ""} + onValueChange={(value) => { + field.onChange(value) + handleSelectSubclass(value) + }} + disabled={!selectedClassOption} + > + <SelectTrigger className="h-9"> + <SelectValue placeholder="서브클래스 선택..." /> + </SelectTrigger> + <SelectContent> + {selectedClassOption?.subclasses.map((subclass) => ( + <SelectItem key={subclass.id} value={subclass.id}> + {subclass.desc} + </SelectItem> + ))} + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + ) + } + + // --------------- // Render TagType field (readonly after class selection) // --------------- function renderTagTypeField(field: any) { @@ -494,8 +566,10 @@ React.useEffect(() => { [isReadOnly] ) + const width = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 ? "w-1/3" : "w-2/3" + return ( - <FormItem className="w-1/2"> + <FormItem className={width}> <FormLabel>Tag Type</FormLabel> <FormControl> {isReadOnly ? ( @@ -536,9 +610,13 @@ React.useEffect(() => { } if (subFields.length === 0 && selectedTagTypeCode) { + const message = selectedClassOption?.subclasses && selectedClassOption.subclasses.length > 0 + ? "서브클래스를 선택해주세요." + : "이 태그 유형에 대한 필드가 없습니다." + return ( <div className="py-4 text-center text-muted-foreground"> - 이 태그 유형에 대한 필드가 없습니다. + {message} </div> ) } @@ -713,9 +791,7 @@ React.useEffect(() => { /> )} </FormControl> - {/* <FormMessage>{sf.expression}</FormMessage> */} </FormItem> - )} /> </TableCell> @@ -779,7 +855,7 @@ React.useEffect(() => { variant="outline" className="w-full border-dashed" onClick={addRow} - disabled={!selectedTagTypeCode || isLoadingSubFields} + disabled={!selectedTagTypeCode || isLoadingSubFields || subFields.length === 0} > <Plus className="h-4 w-4 mr-2" /> 새 행 추가 @@ -808,9 +884,12 @@ React.useEffect(() => { form.reset({ tagType: "", class: "", + subclass: "", rows: [{ tagNo: "", description: "" }] }); setSelectedTagTypeCode(null); + setSelectedClassOption(null); + setSelectedSubclass(""); setSubFields([]); } setOpen(o); @@ -826,7 +905,7 @@ React.useEffect(() => { <DialogHeader> <DialogTitle>새 태그 추가</DialogTitle> <DialogDescription> - 클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요. + 클래스와 서브클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요. </DialogDescription> </DialogHeader> @@ -835,7 +914,7 @@ React.useEffect(() => { onSubmit={form.handleSubmit(onSubmit)} className="space-y-6" > - {/* 클래스 및 태그 유형 선택 */} + {/* 클래스, 서브클래스, 태그 유형 선택 */} <div className="flex gap-4"> <FormField key="class-field" @@ -845,6 +924,13 @@ React.useEffect(() => { /> <FormField + key="subclass-field" + control={form.control} + name="subclass" + render={({ field }) => renderSubclassField(field)} + /> + + <FormField key="tag-type-field" control={form.control} name="tagType" @@ -865,11 +951,14 @@ React.useEffect(() => { form.reset({ tagType: "", class: "", + subclass: "", rows: [{ tagNo: "", description: "" }] }); setOpen(false); setSubFields([]); setSelectedTagTypeCode(null); + setSelectedClassOption(null); + setSelectedSubclass(""); }} disabled={isSubmitting} > |
