summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/mail/templates/safety-pq-approved.hbs15
-rw-r--r--lib/mail/templates/safety-pq-rejected.hbs18
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx63
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx20
-rw-r--r--lib/pq/service.ts317
-rw-r--r--lib/vendors/table/vendors-table-columns.tsx21
6 files changed, 433 insertions, 21 deletions
diff --git a/lib/mail/templates/safety-pq-approved.hbs b/lib/mail/templates/safety-pq-approved.hbs
new file mode 100644
index 00000000..227d4963
--- /dev/null
+++ b/lib/mail/templates/safety-pq-approved.hbs
@@ -0,0 +1,15 @@
+{{!-- 안전적격성 평가 승인 안내 메일 --}}
+<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333;">
+ <p>안녕하세요,</p>
+ <p>안전적격성 평가가 <strong>{{statusText}}</strong>되었습니다.</p>
+ <ul>
+ <li>PQ 번호: {{pqNumber}}</li>
+ <li>업체명: {{vendorName}}</li>
+ </ul>
+ <p>자세한 내용은 eVCP 포털에서 확인해주세요.</p>
+ <p>
+ <a href="https://{{portalUrl}}" target="_blank" style="color:#0ea5e9;">eVCP 바로가기</a>
+ </p>
+ <p>감사합니다.</p>
+</div>
+
diff --git a/lib/mail/templates/safety-pq-rejected.hbs b/lib/mail/templates/safety-pq-rejected.hbs
new file mode 100644
index 00000000..d1499c95
--- /dev/null
+++ b/lib/mail/templates/safety-pq-rejected.hbs
@@ -0,0 +1,18 @@
+{{!-- 안전적격성 평가 거절 안내 메일 --}}
+<div style="font-family: Arial, sans-serif; font-size: 14px; color: #333;">
+ <p>안녕하세요,</p>
+ <p>안전적격성 평가가 <strong>{{statusText}}</strong>되었습니다.</p>
+ <ul>
+ <li>PQ 번호: {{pqNumber}}</li>
+ <li>업체명: {{vendorName}}</li>
+ </ul>
+ {{#if rejectReason}}
+ <p>거절 사유: {{rejectReason}}</p>
+ {{/if}}
+ <p>자세한 내용은 eVCP 포털에서 확인해주세요.</p>
+ <p>
+ <a href="https://{{portalUrl}}" target="_blank" style="color:#ef4444;">eVCP 바로가기</a>
+ </p>
+ <p>감사합니다.</p>
+</div>
+
diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
index a35884fc..dc2afa66 100644
--- a/lib/pq/pq-review-table-new/vendors-table-columns.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx
@@ -295,6 +295,10 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC
return { status: "PQ_IN_PROGRESS", label: "PQ 진행 중", variant: "secondary" as const };
case "SUBMITTED":
return { status: "PQ_SUBMITTED", label: "PQ 제출됨", variant: "default" as const };
+ case "SAFETY_APPROVED":
+ return { status: "PQ_SAFETY_APPROVED", label: "안전 승인됨", variant: "secondary" as const };
+ case "SAFETY_REJECTED":
+ return { status: "PQ_SAFETY_REJECTED", label: "안전 거절됨", variant: "destructive" as const };
case "APPROVED":
return { status: "PQ_APPROVED", label: "PQ 승인됨", variant: "success" as const };
case "REJECTED":
@@ -554,25 +558,56 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ExtendedC
<DataTableColumnHeaderSimple column={column} title="실사품목" />
),
cell: ({ row }) => {
- const pqItems = row.original.pqItems;
+ const pqItems = row.original.pqItems
if (!pqItems) {
- return <span className="text-muted-foreground">-</span>;
+ return <span className="text-muted-foreground">-</span>
}
- // JSON 파싱하여 첫 번째 아이템 표시
- const items = typeof pqItems === 'string' ? JSON.parse(pqItems) : pqItems;
- if (Array.isArray(items) && items.length > 0) {
- const firstItem = items[0];
- return (
- <div className="flex items-center gap-2">
- <span className="text-sm">{firstItem.itemCode} - {firstItem.itemName}</span>
- {items.length > 1 && (
- <span className="text-xs text-muted-foreground">외 {items.length - 1}건</span>
- )}
- </div>
- );
+ // 문자열이면 JSON 파싱을 시도하고, 실패 시 원문 그대로 표시
+ const parsed =
+ typeof pqItems === "string"
+ ? (() => {
+ try {
+ return JSON.parse(pqItems)
+ } catch {
+ return pqItems
+ }
+ })()
+ : pqItems
+
+ if (Array.isArray(parsed)) {
+ if (parsed.length === 0) {
+ return <span className="text-muted-foreground">-</span>
}
+
+ return (
+ <div className="flex flex-wrap gap-1">
+ {parsed.map((item, idx) => {
+ if (!item || typeof item !== "object") return null
+ const itemObj = item as Record<string, any>
+ const displayName =
+ itemObj.materialGroupDescription || itemObj.itemName || ""
+ const displayCode =
+ itemObj.materialGroupCode || itemObj.itemCode || ""
+
+ if (!displayName && !displayCode) return null
+
+ return (
+ <Badge key={`${row.original.id}-pqitem-${idx}`} variant="outline">
+ {displayName || displayCode || "품목"}
+ {displayCode && displayName !== displayCode
+ ? ` (${displayCode})`
+ : ""}
+ </Badge>
+ )
+ })}
+ </div>
+ )
+ }
+
+ // 배열이 아닌 경우 문자열 그대로 표시
+ return <span className="text-sm">{String(parsed) || "-"}</span>
},
meta: {
excelHeader: "실사품목",
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
index a9d37a4b..b7e54f3d 100644
--- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -572,6 +572,11 @@ const handleOpenRequestDialog = async () => {
row.original.type === "NON_INSPECTION"
)
+ // 승인되지 않은 PQ가 포함되었는지 확인
+ const hasNonApprovedStatus = selectedRows.some(row =>
+ row.original.status !== "APPROVED"
+ )
+
// 실사 방법 라벨 변환 함수
const getInvestigationMethodLabel = (method: string): string => {
switch (method) {
@@ -676,9 +681,20 @@ const handleOpenRequestDialog = async () => {
variant="outline"
size="sm"
onClick={handleOpenRequestDialog} // 여기를 수정: 새로운 핸들러 함수 사용
- disabled={isLoading || selectedRows.length === 0 || hasNonInspectionPQ}
+ disabled={
+ isLoading ||
+ selectedRows.length === 0 ||
+ hasNonInspectionPQ ||
+ hasNonApprovedStatus
+ }
className="gap-2"
- title={hasNonInspectionPQ ? "미실사 PQ는 실사 의뢰할 수 없습니다." : undefined}
+ title={
+ hasNonInspectionPQ
+ ? "미실사 PQ는 실사 의뢰할 수 없습니다."
+ : hasNonApprovedStatus
+ ? "승인된 PQ만 실사 의뢰할 수 있습니다."
+ : undefined
+ }
>
<ClipboardCheck className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">실사 의뢰</span>
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index bd83a33c..15e71c4d 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -551,7 +551,7 @@ export async function submitPQAction({
}
// 제출 가능한 상태 확인
- const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "SUBMITTED", "REJECTED"];
+ const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "SUBMITTED", "REJECTED", "SAFETY_REJECTED"];
if (existingSubmission) {
if (!allowedStatuses.includes(existingSubmission.status)) {
@@ -2277,6 +2277,289 @@ export async function updateSHICommentAction({
}
}
+// 안전 PQ 승인 액션 (HSE 단계)
+export async function approveSafetyPQAction({
+ pqSubmissionId,
+ vendorId,
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+}) {
+ unstable_noStore();
+
+ try {
+ const currentDate = new Date();
+
+ const pqSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ status: vendorPQSubmissions.status,
+ type: vendorPQSubmissions.type,
+ pqNumber: vendorPQSubmissions.pqNumber,
+ requesterId: vendorPQSubmissions.requesterId,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!pqSubmission) {
+ return { ok: false, error: "PQ submission not found" };
+ }
+
+ if (pqSubmission.status !== "SUBMITTED") {
+ return {
+ ok: false,
+ error: `Cannot approve safety PQ in current status: ${pqSubmission.status}`
+ };
+ }
+
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "SAFETY_APPROVED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 벤더 안전적격성 통과 표시 (해당없음 → 승인)
+ await db
+ .update(vendors)
+ .set({
+ safetyQualificationPassed: true,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+
+ // 메일 발송: PQ 제출자 + 벤더
+ const headersList = await headers();
+ const host = headersList.get("host") || "localhost:3000";
+ const portalUrl = `${host}/partners/pq_new`;
+
+ // 벤더 정보 조회
+ const vendorInfo = await db
+ .select({
+ vendorName: vendors.vendorName,
+ vendorEmail: vendors.email,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ // PQ 제출자 이메일 조회
+ let requesterEmail: string | null = null;
+ if (pqSubmission.requesterId) {
+ const requester = await db
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, pqSubmission.requesterId))
+ .then(rows => rows[0]);
+ requesterEmail = requester?.email || null;
+ }
+
+ const emailContext = {
+ pqNumber: pqSubmission.pqNumber || `PQ-${pqSubmission.id}`,
+ vendorName: vendorInfo?.vendorName || "",
+ statusText: "승인",
+ portalUrl,
+ };
+
+ // 벤더 메일
+ if (vendorInfo?.vendorEmail) {
+ try {
+ await sendEmail({
+ to: vendorInfo.vendorEmail,
+ subject: `[eVCP] 안전적격성 평가 승인 안내 (${emailContext.pqNumber})`,
+ template: "safety-pq-approved",
+ context: emailContext,
+ });
+ } catch (emailError) {
+ console.error("Failed to send safety approve email to vendor:", emailError);
+ }
+ }
+
+ // PQ 제출자 메일
+ if (requesterEmail) {
+ try {
+ await sendEmail({
+ to: requesterEmail,
+ subject: `[eVCP] 안전적격성 평가 승인 안내 (${emailContext.pqNumber})`,
+ template: "safety-pq-approved",
+ context: emailContext,
+ });
+ } catch (emailError) {
+ console.error("Failed to send safety approve email to requester:", emailError);
+ }
+ }
+
+ revalidateTag("pq-submissions");
+ revalidateTag(`vendor-pq-submissions-${vendorId}`);
+ revalidateTag("vendors");
+ if (pqSubmission.projectId) {
+ revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`);
+ }
+ revalidatePath("/evcp/pq_new");
+
+ return { ok: true };
+ } catch (error) {
+ console.error("Safety PQ approve error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
+// 안전 PQ 거절 액션 (HSE 단계)
+export async function rejectSafetyPQAction({
+ pqSubmissionId,
+ vendorId,
+ rejectReason,
+}: {
+ pqSubmissionId: number;
+ vendorId: number;
+ rejectReason: string;
+}) {
+ unstable_noStore();
+
+ try {
+ const currentDate = new Date();
+
+ const pqSubmission = await db
+ .select({
+ id: vendorPQSubmissions.id,
+ vendorId: vendorPQSubmissions.vendorId,
+ projectId: vendorPQSubmissions.projectId,
+ status: vendorPQSubmissions.status,
+ type: vendorPQSubmissions.type,
+ pqNumber: vendorPQSubmissions.pqNumber,
+ requesterId: vendorPQSubmissions.requesterId,
+ })
+ .from(vendorPQSubmissions)
+ .where(
+ and(
+ eq(vendorPQSubmissions.id, pqSubmissionId),
+ eq(vendorPQSubmissions.vendorId, vendorId)
+ )
+ )
+ .then(rows => rows[0]);
+
+ if (!pqSubmission) {
+ return { ok: false, error: "PQ submission not found" };
+ }
+
+ if (pqSubmission.status !== "SUBMITTED") {
+ return {
+ ok: false,
+ error: `Cannot reject safety PQ in current status: ${pqSubmission.status}`
+ };
+ }
+
+ await db
+ .update(vendorPQSubmissions)
+ .set({
+ status: "SAFETY_REJECTED",
+ rejectedAt: currentDate,
+ rejectReason,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendorPQSubmissions.id, pqSubmissionId));
+
+ // 벤더 안전적격성 통과 여부를 거절로 기록
+ await db
+ .update(vendors)
+ .set({
+ safetyQualificationPassed: false,
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+
+ // 메일 발송: PQ 제출자 + 벤더
+ const headersList = await headers();
+ const host = headersList.get("host") || "localhost:3000";
+ const portalUrl = `${host}/partners/pq_new`;
+
+ const vendorInfo = await db
+ .select({
+ vendorName: vendors.vendorName,
+ vendorEmail: vendors.email,
+ })
+ .from(vendors)
+ .where(eq(vendors.id, vendorId))
+ .then(rows => rows[0]);
+
+ let requesterEmail: string | null = null;
+ if (pqSubmission.requesterId) {
+ const requester = await db
+ .select({ email: users.email })
+ .from(users)
+ .where(eq(users.id, pqSubmission.requesterId))
+ .then(rows => rows[0]);
+ requesterEmail = requester?.email || null;
+ }
+
+ const emailContext = {
+ pqNumber: pqSubmission.pqNumber || `PQ-${pqSubmission.id}`,
+ vendorName: vendorInfo?.vendorName || "",
+ statusText: "거절",
+ rejectReason: rejectReason || "",
+ portalUrl,
+ };
+
+ if (vendorInfo?.vendorEmail) {
+ try {
+ await sendEmail({
+ to: vendorInfo.vendorEmail,
+ subject: `[eVCP] 안전적격성 평가 거절 안내 (${emailContext.pqNumber})`,
+ template: "safety-pq-rejected",
+ context: emailContext,
+ });
+ } catch (emailError) {
+ console.error("Failed to send safety reject email to vendor:", emailError);
+ }
+ }
+
+ if (requesterEmail) {
+ try {
+ await sendEmail({
+ to: requesterEmail,
+ subject: `[eVCP] 안전적격성 평가 거절 안내 (${emailContext.pqNumber})`,
+ template: "safety-pq-rejected",
+ context: emailContext,
+ });
+ } catch (emailError) {
+ console.error("Failed to send safety reject email to requester:", emailError);
+ }
+ }
+
+ if (pqSubmission.type === "GENERAL") {
+ await db
+ .update(vendors)
+ .set({
+ status: "PQ_FAILED",
+ updatedAt: currentDate,
+ })
+ .where(eq(vendors.id, vendorId));
+ }
+
+ revalidateTag("pq-submissions");
+ revalidateTag(`vendor-pq-submissions-${vendorId}`);
+ revalidateTag("vendors");
+ if (pqSubmission.projectId) {
+ revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`);
+ }
+ revalidatePath("/evcp/pq_new");
+
+ return { ok: true };
+ } catch (error) {
+ console.error("Safety PQ reject error:", error);
+ return { ok: false, error: getErrorMessage(error) };
+ }
+}
+
// PQ 승인 액션
export async function approvePQAction({
pqSubmissionId,
@@ -2314,8 +2597,9 @@ export async function approvePQAction({
return { ok: false, error: "PQ submission not found" };
}
- // 2. 상태 확인 (SUBMITTED 상태만 승인 가능)
- if (pqSubmission.status !== "SUBMITTED") {
+ // 2. 상태 확인 (안전 승인 이후 승인 가능, 기존 SUBMITTED는 호환용)
+ const allowedStatuses = ["SAFETY_APPROVED", "SUBMITTED"];
+ if (!allowedStatuses.includes(pqSubmission.status)) {
return {
ok: false,
error: `Cannot approve PQ in current status: ${pqSubmission.status}`
@@ -2363,6 +2647,27 @@ export async function approvePQAction({
})
.where(eq(vendorPQSubmissions.id, pqSubmissionId));
+ // 5-1. 미실사 PQ라면 구매자체평가 실사 레코드를 자동 생성
+ if (pqSubmission.type === "NON_INSPECTION") {
+ const existingInvestigation = await db
+ .select({ id: vendorInvestigations.id })
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.pqSubmissionId, pqSubmissionId))
+ .limit(1)
+ .then(rows => rows[0]);
+
+ if (!existingInvestigation) {
+ await db.insert(vendorInvestigations).values({
+ vendorId,
+ pqSubmissionId,
+ investigationStatus: "IN_PROGRESS",
+ investigationMethod: "PURCHASE_SELF_EVAL",
+ requestedAt: currentDate,
+ updatedAt: currentDate,
+ });
+ }
+ }
+
// 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항)
if (pqSubmission.type === "GENERAL") {
await db
@@ -2788,8 +3093,9 @@ export async function rejectPQAction({
return { ok: false, error: "PQ submission not found" };
}
- // 2. 상태 확인 (SUBMITTED 상태만 거부 가능)
- if (pqSubmission.status !== "SUBMITTED") {
+ // 2. 상태 확인 (안전 승인 이후 거부 가능, 기존 SUBMITTED는 호환용)
+ const allowedStatuses = ["SAFETY_APPROVED", "SUBMITTED"];
+ if (!allowedStatuses.includes(pqSubmission.status)) {
return {
ok: false,
error: `Cannot reject PQ in current status: ${pqSubmission.status}`
@@ -4872,6 +5178,7 @@ export async function updateInvestigationDetailsAction(input: {
});
revalidateTag("pq-submissions");
+ revalidateTag("vendor-regular-registrations");
revalidatePath("/evcp/pq_new");
return {
diff --git a/lib/vendors/table/vendors-table-columns.tsx b/lib/vendors/table/vendors-table-columns.tsx
index 36809715..b147f1ef 100644
--- a/lib/vendors/table/vendors-table-columns.tsx
+++ b/lib/vendors/table/vendors-table-columns.tsx
@@ -353,6 +353,27 @@ export function getColumns({ setRowAction, router, userId }: GetColumnsProps): C
);
}
+ // 안전적격성 평가 통과 여부 컬럼 처리
+ if (cfg.id === "safetyQualificationPassed") {
+ const val = row.original.safetyQualificationPassed as boolean | null | undefined;
+ const getBadge = (value: boolean | null | undefined) => {
+ if (value === true) {
+ return { text: "승인", className: "bg-green-100 text-green-800 border-green-300" };
+ }
+ if (value === false) {
+ return { text: "거절", className: "bg-red-100 text-red-800 border-red-300" };
+ }
+ return { text: "해당없음", className: "bg-gray-100 text-gray-700 border-gray-300" };
+ };
+
+ const badge = getBadge(val);
+ return (
+ <Badge variant="outline" className={badge.className}>
+ {badge.text}
+ </Badge>
+ );
+ }
+
// 업체 유형 컬럼 처리
if (cfg.id === "vendorTypeName") {
const typeVal = row.original.vendorTypeName as string | null;