summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-17 10:50:28 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-17 10:50:28 +0000
commitfb276ed3db86fe4fc0c0fcd870fd3d085b034be0 (patch)
tree4a8ab1027d7fd14602a0f837d4e18b04e2169e58 /lib
parent4eb7532f822c821fb6b69bf103bd075fefba769b (diff)
(대표님) 벤더데이터 S-EDP 변경사항 대응(seperator), 정기평가 점수오류, dim 준비
Diffstat (limited to 'lib')
-rw-r--r--lib/equip-class/repository.ts2
-rw-r--r--lib/equip-class/table/equipClass-table-columns.tsx143
-rw-r--r--lib/equip-class/table/equipClass-table.tsx27
-rw-r--r--lib/evaluation/service.ts179
-rw-r--r--lib/items-tech/service.ts236
-rw-r--r--lib/mail/sendEmail.ts9
-rw-r--r--lib/mail/templates/tech-vendor-invitation.hbs148
-rw-r--r--lib/mail/templates/vendor-evalution-request.hbs327
-rw-r--r--lib/sedp/get-form-tags.ts5
-rw-r--r--lib/sedp/get-tags.ts1
-rw-r--r--lib/sedp/sync-form.ts4
-rw-r--r--lib/sedp/sync-object-class.ts442
-rw-r--r--lib/tags/service.ts181
-rw-r--r--lib/tags/table/add-tag-dialog.tsx199
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}
>