diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-01 09:48:03 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-10-01 09:48:03 +0000 |
| commit | 33e8452331c301430191b3506825ebaf3edac93a (patch) | |
| tree | 6d92d754dbd30cafe0f3f920a14d6d6031c624b8 | |
| parent | 8ac4e8d9faa6e86ca6c7ab475efd7462d76fc9b6 (diff) | |
(최겸) 구매 PQ 리스트 기능 수정, 견적 첨부파일 리비전 액션 추가, 기타 등
| -rw-r--r-- | components/pq-input/pq-input-tabs.tsx | 2 | ||||
| -rw-r--r-- | lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx | 16 | ||||
| -rw-r--r-- | lib/mail/templates/pq-approved-vendor.hbs | 126 | ||||
| -rw-r--r-- | lib/mail/templates/pq-rejected-vendor.hbs | 143 | ||||
| -rw-r--r-- | lib/pq/pq-criteria/pq-table-column.tsx | 11 | ||||
| -rw-r--r-- | lib/pq/service.ts | 133 | ||||
| -rw-r--r-- | lib/pq/table/add-pq-list-dialog.tsx | 34 | ||||
| -rw-r--r-- | lib/pq/table/copy-pq-list-dialog.tsx | 80 | ||||
| -rw-r--r-- | lib/pq/table/pq-lists-columns.tsx | 127 | ||||
| -rw-r--r-- | lib/pq/table/pq-lists-table.tsx | 45 | ||||
| -rw-r--r-- | lib/pq/table/pq-lists-toolbar.tsx | 4 | ||||
| -rw-r--r-- | lib/pq/validations.ts | 16 | ||||
| -rw-r--r-- | lib/rfq-last/attachment/rfq-attachments-table.tsx | 36 | ||||
| -rw-r--r-- | lib/rfq-last/vendor/rfq-vendor-table.tsx | 2 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 40 | ||||
| -rw-r--r-- | lib/vendors/table/request-pq-dialog.tsx | 14 |
16 files changed, 698 insertions, 131 deletions
diff --git a/components/pq-input/pq-input-tabs.tsx b/components/pq-input/pq-input-tabs.tsx index 4e5272f5..a37a52db 100644 --- a/components/pq-input/pq-input-tabs.tsx +++ b/components/pq-input/pq-input-tabs.tsx @@ -1078,7 +1078,7 @@ export function PQInputTabs({ name={`answers.${answerIndex}.vendorReply`} render={({ field }) => ( <FormItem className="mt-2"> - <FormLabel className="text-blue-600">벤더 Reply</FormLabel> + <FormLabel className="text-blue-600">벤더 Reply (선택사항)</FormLabel> <FormControl> <Textarea {...field} diff --git a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx index 759f7cac..47a371d9 100644 --- a/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx +++ b/lib/basic-contract/vendor-table/basic-contract-sign-dialog.tsx @@ -420,10 +420,10 @@ const canCompleteCurrentContract = React.useMemo(() => { const nextContract = getNextPendingContract(); if (nextContract) { setSelectedContract(nextContract); - toast.info(`다음 계약서로 이동합니다`, { - description: nextContract.templateName, - icon: <ArrowRight className="h-4 w-4 text-blue-500" /> - }); + // toast.info(`다음 계약서로 이동합니다`, { + // description: nextContract.templateName, + // icon: <ArrowRight className="h-4 w-4 text-blue-500" /> + // }); } else { // 모든 계약서 완료시 toast.success("🎉 모든 계약서 최종승인이 완료되었습니다!", { @@ -488,10 +488,10 @@ const canCompleteCurrentContract = React.useMemo(() => { const nextContract = getNextPendingContract(); if (nextContract) { setSelectedContract(nextContract); - toast.info(`다음 계약서로 이동합니다`, { - description: nextContract.templateName, - icon: <ArrowRight className="h-4 w-4 text-blue-500" /> - }); + // toast.info(`다음 계약서로 이동합니다`, { + // description: nextContract.templateName, + // icon: <ArrowRight className="h-4 w-4 text-blue-500" /> + // }); } else { // 모든 계약서 완료시 toast.success("🎉 모든 계약서 서명이 완료되었습니다!", { diff --git a/lib/mail/templates/pq-approved-vendor.hbs b/lib/mail/templates/pq-approved-vendor.hbs new file mode 100644 index 00000000..1ded76ef --- /dev/null +++ b/lib/mail/templates/pq-approved-vendor.hbs @@ -0,0 +1,126 @@ +<!DOCTYPE html> +<html> +<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; + } + .email-container { + max-width: 600px; + margin: 0 auto; + background-color: #ffffff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + .success-badge { + display: inline-block; + background-color: #10b981; + color: white; + padding: 4px 12px; + border-radius: 16px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 16px; + } + </style> +</head> +<body> + <div class="email-container"> + <!-- Header --> + <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;"> + <tr> + <td align="center"> + <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span> + </td> + </tr> + </table> + + <!-- Success Badge --> + <div class="success-badge"> + 승인 완료 + </div> + + <!-- Title --> + <h1 style="font-size:28px; margin-bottom:16px; color:#111827;"> + {{#if isProjectPQ}} + 프로젝트 PQ가 승인되었습니다! + {{else}} + 일반 PQ가 승인되었습니다! + {{/if}} + </h1> + + <!-- Greeting --> + <p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + 안녕하세요, <strong>{{vendorName}}</strong> 담당자님. + </p> + + <!-- Main Content --> + <p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{#if isProjectPQ}} + 축하합니다! 귀사의 <strong>{{projectName}}</strong> 프로젝트 PQ가 승인되었습니다. + {{else}} + 축하합니다! 귀사의 일반 PQ가 승인되었습니다. + {{/if}} + </p> + + <!-- PQ Details --> + <div style="background-color:#f0f9ff; border-left: 4px solid #163CC4; padding:16px; margin:16px 0; border-radius: 0 8px 8px 0;"> + <h3 style="margin-top:0; margin-bottom:12px; color:#163CC4;">PQ 정보</h3> + <p style="font-size:14px; margin:4px 0; color:#374151;"> + <strong>업체명:</strong> {{vendorName}} + </p> + {{#if isProjectPQ}} + <p style="font-size:14px; margin:4px 0; color:#374151;"> + <strong>프로젝트명:</strong> {{projectName}} + </p> + {{/if}} + <p style="font-size:14px; margin:4px 0; color:#374151;"> + <strong>승인일:</strong> {{approvedDate}} + </p> + </div> + + <!-- Next Steps --> + <div style="background-color:#f9fafb; padding:16px; border-radius:8px; margin:16px 0;"> + <h3 style="margin-top:0; margin-bottom:12px; color:#374151;">다음 단계</h3> + <p style="font-size:14px; margin:4px 0; color:#6b7280;"> + PQ 승인으로 인해 {{#if isProjectPQ}}프로젝트 참여 자격이{{else}}업체 등록이{{/if}} 완료되었습니다. + 이제 관련 프로젝트 및 입찰 기회에 참여하실 수 있습니다. + </p> + </div> + + <!-- Action Button --> + <div style="text-align: center; margin: 24px 0;"> + <a href="{{portalUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin: 8px 0;"> + 협력업체 포털로 이동 + </a> + </div> + + <!-- Support Message --> + <p style="font-size:16px; line-height:24px; margin-top:24px; color:#6b7280;"> + 궁금한 사항이 있으시면 언제든지 문의해주세요. + </p> + + <!-- Footer --> + <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;"> + <tr> + <td align="center"> + <p style="font-size:14px; color:#6b7280; margin:4px 0;"> + © {{currentYear}} EVCP. 모든 권리 보유. + </p> + <p style="font-size:14px; color:#6b7280; margin:4px 0;"> + 본 이메일은 발신 전용입니다. 회신하지 마세요. + </p> + </td> + </tr> + </table> + </div> +</body> +</html> diff --git a/lib/mail/templates/pq-rejected-vendor.hbs b/lib/mail/templates/pq-rejected-vendor.hbs new file mode 100644 index 00000000..3cb8aea6 --- /dev/null +++ b/lib/mail/templates/pq-rejected-vendor.hbs @@ -0,0 +1,143 @@ +<!DOCTYPE html> +<html> +<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; + } + .email-container { + max-width: 600px; + margin: 0 auto; + background-color: #ffffff; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + } + .rejected-badge { + display: inline-block; + background-color: #ef4444; + color: white; + padding: 4px 12px; + border-radius: 16px; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + margin-bottom: 16px; + } + .warning-box { + background-color: #fef2f2; + border-left: 4px solid #ef4444; + padding: 16px; + margin: 16px 0; + border-radius: 0 8px 8px 0; + } + </style> +</head> +<body> + <div class="email-container"> + <!-- Header --> + <table width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:24px; border-bottom:1px solid #163CC4; padding-bottom:16px;"> + <tr> + <td align="center"> + <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span> + </td> + </tr> + </table> + + <!-- Rejected Badge --> + <div class="rejected-badge"> + 거부됨 + </div> + + <!-- Title --> + <h1 style="font-size:28px; margin-bottom:16px; color:#111827;"> + {{#if isProjectPQ}} + 프로젝트 PQ가 거부되었습니다 + {{else}} + 일반 PQ가 거부되었습니다 + {{/if}} + </h1> + + <!-- Greeting --> + <p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + 안녕하세요, <strong>{{vendorName}}</strong> 담당자님. + </p> + + <!-- Main Content --> + <p style="font-size:16px; line-height:32px; margin-bottom:16px;"> + {{#if isProjectPQ}} + 귀사의 <strong>{{projectName}}</strong> 프로젝트 PQ가 검토 결과 거부되었습니다. + {{else}} + 귀사의 일반 PQ가 검토 결과 거부되었습니다. + {{/if}} + </p> + + <!-- PQ Details --> + <div style="background-color:#f9fafb; padding:16px; border-radius:8px; margin:16px 0;"> + <h3 style="margin-top:0; margin-bottom:12px; color:#374151;">PQ 정보</h3> + <p style="font-size:14px; margin:4px 0; color:#374151;"> + <strong>업체명:</strong> {{vendorName}} + </p> + {{#if isProjectPQ}} + <p style="font-size:14px; margin:4px 0; color:#374151;"> + <strong>프로젝트명:</strong> {{projectName}} + </p> + {{/if}} + <p style="font-size:14px; margin:4px 0; color:#374151;"> + <strong>거부일:</strong> {{rejectedDate}} + </p> + </div> + + <!-- Rejection Reason --> + {{#if rejectReason}} + <div class="warning-box"> + <h3 style="margin-top:0; margin-bottom:12px; color:#dc2626;">거부 사유</h3> + <p style="font-size:14px; margin:4px 0; color:#7f1d1d; line-height: 1.5;"> + {{rejectReason}} + </p> + </div> + {{/if}} + + <!-- Next Steps --> + <div style="background-color:#fef3c7; border:1px solid #f59e0b; padding:16px; border-radius:8px; margin:16px 0;"> + <h3 style="margin-top:0; margin-bottom:12px; color:#92400e;">개선 안내</h3> + <p style="font-size:14px; margin:4px 0; color:#92400e; line-height: 1.5;"> + 거부 사유를 확인하시고 필요한 사항을 보완한 후 PQ를 다시 제출해 주시기 바랍니다. + 추가 문의사항이 있으시면 언제든지 연락해 주세요. + </p> + </div> + + <!-- Action Button --> + <div style="text-align: center; margin: 24px 0;"> + <a href="{{portalUrl}}" target="_blank" style="display: inline-block; width: 250px; padding: 12px 20px; background-color: #163CC4; color: #ffffff !important; text-decoration: none; border-radius: 8px; text-align: center; line-height: 28px; margin: 8px 0;"> + 협력업체 포털로 이동 + </a> + </div> + + <!-- Support Message --> + <p style="font-size:16px; line-height:24px; margin-top:24px; color:#6b7280;"> + 문의사항이 있으시면 언제든지 연락해 주시기 바랍니다. + </p> + + <!-- Footer --> + <table width="100%" cellpadding="0" cellspacing="0" style="margin-top:32px; border-top:1px solid #e5e7eb; padding-top:16px;"> + <tr> + <td align="center"> + <p style="font-size:14px; color:#6b7280; margin:4px 0;"> + © {{currentYear}} EVCP. 모든 권리 보유. + </p> + <p style="font-size:14px; color:#6b7280; margin:4px 0;"> + 본 이메일은 발신 전용입니다. 회신하지 마세요. + </p> + </td> + </tr> + </table> + </div> +</body> +</html> diff --git a/lib/pq/pq-criteria/pq-table-column.tsx b/lib/pq/pq-criteria/pq-table-column.tsx index de7396bf..32d6cc32 100644 --- a/lib/pq/pq-criteria/pq-table-column.tsx +++ b/lib/pq/pq-criteria/pq-table-column.tsx @@ -1,6 +1,7 @@ "use client" import * as React from "react" +import { useMemo } from "react" import { ColumnDef } from "@tanstack/react-table" import { formatDate } from "@/lib/utils" import { Checkbox } from "@/components/ui/checkbox" @@ -179,7 +180,10 @@ export function getColumns({ header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="생성일" /> ), - cell: ({ cell }) => formatDate(cell.getValue() as Date, "ko-KR"), + cell: ({ cell }) => { + const dateValue = cell.getValue() as Date + return useMemo(() => formatDate(dateValue, "ko-KR"), [dateValue]) + }, enableResizing: true, minSize: 180, size: 180, @@ -189,7 +193,10 @@ export function getColumns({ header: ({ column }) => ( <DataTableColumnHeaderSimple column={column} title="수정일" /> ), - cell: ({ cell }) => formatDate(cell.getValue() as Date, "ko-KR"), + cell: ({ cell }) => { + const dateValue = cell.getValue() as Date + return useMemo(() => formatDate(dateValue, "ko-KR"), [dateValue]) + }, enableResizing: true, minSize: 180, size: 180, diff --git a/lib/pq/service.ts b/lib/pq/service.ts index 7aa80dfa..67be5398 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -1,7 +1,7 @@ "use server"
import db from "@/db/db"
-import { CopyPqListInput, CreatePqListInput, copyPqListSchema, createPqListSchema, GetPqListsSchema, GetPQSchema, GetPQSubmissionsSchema } from "./validations"
+import { CopyPqListInput, CreatePqListInput, UpdatePqValidToInput, copyPqListSchema, createPqListSchema, updatePqValidToSchema, GetPqListsSchema, GetPQSchema, GetPQSubmissionsSchema } from "./validations"
import { unstable_cache } from "@/lib/unstable-cache";
import { filterColumns } from "@/lib/filter-columns";
import { getErrorMessage } from "@/lib/handle-error";
@@ -2977,7 +2977,9 @@ export async function getPQLists(input: GetPqListsSchema) { const s = `%${input.search}%`;
globalWhere = or(
ilike(pqLists.name, s),
- ilike(pqLists.type, s)
+ ilike(pqLists.type, s),
+ ilike(projects.code, s),
+ ilike(projects.name, s)
);
}
@@ -3125,6 +3127,17 @@ export async function createPQListAction(input: CreatePqListInput) { // 프로젝트 PQ인 경우 중복 체크
if (validated.type === "PROJECT" && validated.projectId) {
+ // 프로젝트 정보 조회 (이름과 코드 포함)
+ const projectInfo = await db
+ .select({
+ code: projects.code,
+ name: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.id, validated.projectId))
+ .limit(1)
+ .then(rows => rows[0]);
+
const existingPQ = await db
.select()
.from(pqLists)
@@ -3136,11 +3149,12 @@ export async function createPQListAction(input: CreatePqListInput) { )
)
.limit(1);
-
+
if (existingPQ.length > 0) {
+ const projectDisplayName = projectInfo ? `${projectInfo.code} - ${projectInfo.name}` : "알 수 없는 프로젝트";
return {
success: false,
- error: "해당 프로젝트에 대한 PQ가 이미 존재합니다"
+ error: `${projectDisplayName} 프로젝트에 대한 PQ가 이미 존재합니다`
};
}
}
@@ -3315,24 +3329,38 @@ export async function copyPQListAction(input: CopyPqListInput) { };
}
- // 2. 대상 프로젝트에 이미 PQ가 존재하는지 확인
- const existingProjectPQ = await tx
- .select()
- .from(pqLists)
- .where(
- and(
- eq(pqLists.projectId, validated.targetProjectId),
- eq(pqLists.type, "PROJECT"),
- eq(pqLists.isDeleted, false)
- )
- )
- .limit(1);
+ // 2. 프로젝트 PQ인 경우에만 대상 프로젝트에 이미 PQ가 존재하는지 확인
+ if (sourcePqList.type === "PROJECT" && validated.targetProjectId) {
+ // 프로젝트 정보 조회 (이름과 코드 포함)
+ const projectInfo = await tx
+ .select({
+ code: projects.code,
+ name: projects.name
+ })
+ .from(projects)
+ .where(eq(projects.id, validated.targetProjectId))
+ .limit(1)
+ .then(rows => rows[0]);
- if (existingProjectPQ.length > 0) {
- return {
- success: false,
- error: "해당 프로젝트에 대한 PQ가 이미 존재합니다"
- };
+ const existingProjectPQ = await tx
+ .select()
+ .from(pqLists)
+ .where(
+ and(
+ eq(pqLists.projectId, validated.targetProjectId),
+ eq(pqLists.type, "PROJECT"),
+ eq(pqLists.isDeleted, false)
+ )
+ )
+ .limit(1);
+
+ if (existingProjectPQ.length > 0) {
+ const projectDisplayName = projectInfo ? `${projectInfo.code} - ${projectInfo.name}` : "알 수 없는 프로젝트";
+ return {
+ success: false,
+ error: `${projectDisplayName} 프로젝트에 대한 PQ가 이미 존재합니다`
+ };
+ }
}
// 3. 새 PQ 목록 생성
@@ -3344,7 +3372,7 @@ export async function copyPQListAction(input: CopyPqListInput) { .values({
name: newName || sourcePqList.name,
type: sourcePqList.type,
- projectId: validated.targetProjectId,
+ projectId: sourcePqList.type === "PROJECT" ? validated.targetProjectId : null,
isDeleted: false,
createdAt: now,
updatedAt: now,
@@ -3957,6 +3985,67 @@ export async function autoDeactivateExpiredPQLists() { }
}
+// PQ 유효일 수정 서버액션
+export async function updatePqValidToAction(input: UpdatePqValidToInput) {
+ try {
+ const validated = updatePqValidToSchema.parse(input);
+ const session = await getServerSession(authOptions);
+ const userId = session?.user?.id;
+
+ if (!userId) {
+ return {
+ success: false,
+ error: "인증이 필요합니다"
+ };
+ }
+
+ // PQ 목록 존재 확인
+ const existingPqList = await db
+ .select()
+ .from(pqLists)
+ .where(eq(pqLists.id, validated.pqListId))
+ .limit(1)
+ .then(rows => rows[0]);
+
+ if (!existingPqList) {
+ return {
+ success: false,
+ error: "PQ 목록을 찾을 수 없습니다"
+ };
+ }
+
+ // 유효일 업데이트
+ await db
+ .update(pqLists)
+ .set({
+ validTo: validated.validTo,
+ updatedAt: new Date(),
+ updatedBy: userId,
+ })
+ .where(eq(pqLists.id, validated.pqListId));
+
+ // 캐시 재검증
+ revalidateTag("pq-lists");
+
+ return {
+ success: true,
+ message: "유효일이 성공적으로 수정되었습니다"
+ };
+ } catch (error) {
+ console.error("Error updating PQ valid to:", error);
+ if (error instanceof z.ZodError) {
+ return {
+ success: false,
+ error: "입력 데이터가 올바르지 않습니다"
+ };
+ }
+ return {
+ success: false,
+ error: "유효일 수정에 실패했습니다"
+ };
+ }
+}
+
// SHI 참석자 총 인원수 계산 함수
export async function getTotalShiAttendees(shiAttendees: Record<string, unknown> | null): Promise<number> {
diff --git a/lib/pq/table/add-pq-list-dialog.tsx b/lib/pq/table/add-pq-list-dialog.tsx index c1899a29..472a1b3d 100644 --- a/lib/pq/table/add-pq-list-dialog.tsx +++ b/lib/pq/table/add-pq-list-dialog.tsx @@ -10,13 +10,8 @@ import { DatePicker } from "@/components/ui/date-picker" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Loader2, Plus } from "lucide-react"
+import { ProjectSelector } from "@/components/ProjectSelector"
-// 프로젝트 목록을 위한 임시 타입 (실제로는 projects에서 가져와야 함)
-interface Project {
- id: number
- name: string
- code: string
-}
const pqListFormSchema = z.object({
name: z.string().min(1, "PQ 목록 명을 입력해주세요"),
@@ -42,7 +37,6 @@ interface PqListFormProps { open: boolean
onOpenChange: (open: boolean) => void
initialData?: Partial<PqListFormData> & { id?: number }
- projects?: Project[]
onSubmit: (data: PqListFormData & { id?: number }) => Promise<void>
isLoading?: boolean
}
@@ -57,7 +51,6 @@ export function AddPqDialog({ open,
onOpenChange,
initialData,
- projects = [],
onSubmit,
isLoading = false
}: PqListFormProps) {
@@ -162,23 +155,13 @@ export function AddPqDialog({ <FormLabel className="flex items-center gap-1">
프로젝트 <span className="text-red-500">*</span>
</FormLabel>
- <Select
- onValueChange={(value) => field.onChange(parseInt(value))}
- defaultValue={field.value?.toString()}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="프로젝트를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {projects.map((project) => (
- <SelectItem key={project.id} value={project.id.toString()}>
- {project.code} - {project.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
+ <FormControl>
+ <ProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={(project) => field.onChange(project.id)}
+ placeholder="프로젝트를 선택하세요"
+ />
+ </FormControl>
<FormMessage />
</FormItem>
)}
@@ -200,6 +183,7 @@ export function AddPqDialog({ date={field.value ?? undefined}
onSelect={(date) => field.onChange(date ?? null)}
placeholder="유효일 선택"
+ minDate={new Date()}
/>
</FormControl>
<FormMessage />
diff --git a/lib/pq/table/copy-pq-list-dialog.tsx b/lib/pq/table/copy-pq-list-dialog.tsx index 647ab1a3..51b7eed1 100644 --- a/lib/pq/table/copy-pq-list-dialog.tsx +++ b/lib/pq/table/copy-pq-list-dialog.tsx @@ -33,11 +33,17 @@ const copyPqSchema = z.object({ sourcePqListId: z.number({
required_error: "복사할 PQ 목록을 선택해주세요"
}),
- targetProjectId: z.number({
- required_error: "대상 프로젝트를 선택해주세요"
- }),
+ targetProjectId: z.number().optional(),
validTo: z.date(),
newName: z.string(),
+}).refine((data) => {
+ // 미실사 PQ가 아닌 경우에만 targetProjectId 필수
+ if (data.targetProjectId !== undefined) return true
+ // 미실사 PQ인 경우 targetProjectId는 선택사항
+ return true
+}, {
+ message: "프로젝트 PQ인 경우 대상 프로젝트를 선택해야 합니다",
+ path: ["targetProjectId"]
})
type CopyPqFormData = z.infer<typeof copyPqSchema>
@@ -106,36 +112,6 @@ export function CopyPqDialog({ <Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
- {/* 대상 프로젝트 선택 */}
- <FormField
- control={form.control}
- name="targetProjectId"
- render={({ field }) => (
- <FormItem>
- <FormLabel className="flex items-center gap-1">
- 대상 프로젝트 <span className="text-red-500">*</span>
- </FormLabel>
- <Select
- onValueChange={(value) => field.onChange(parseInt(value))}
- defaultValue={field.value?.toString()}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="PQ를 적용할 프로젝트를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {projects.map((project) => (
- <SelectItem key={project.id} value={project.id.toString()}>
- {project.code} - {project.name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
{/* 복사할 PQ 목록 선택 */}
<FormField
control={form.control}
@@ -155,7 +131,9 @@ export function CopyPqDialog({ </SelectTrigger>
</FormControl>
<SelectContent>
- {pqLists.map((pqList) => (
+ {pqLists
+ .filter(pqList => pqList.type !== "GENERAL") // 일반 PQ 제외
+ .map((pqList) => (
<SelectItem key={pqList.id} value={pqList.id.toString()}>
<div className="flex items-center gap-2">
<Badge className={typeColors[pqList.type]}>
@@ -184,6 +162,39 @@ export function CopyPqDialog({ </FormItem>
)}
/>
+ {/* 대상 프로젝트 선택 (미실사 PQ가 아닌 경우에만) */}
+ {selectedPqList?.type !== "NON_INSPECTION" && (
+ <FormField
+ control={form.control}
+ name="targetProjectId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-1">
+ 대상 프로젝트 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select
+ onValueChange={(value) => field.onChange(parseInt(value))}
+ defaultValue={field.value?.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="PQ를 적용할 프로젝트를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {projects.map((project) => (
+ <SelectItem key={project.id} value={project.id.toString()}>
+ {project.code} - {project.name}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
{/* 새 PQ 목록 명 */}
<FormField
control={form.control}
@@ -214,6 +225,7 @@ export function CopyPqDialog({ date={field.value ?? undefined}
onSelect={(date) => field.onChange(date ?? null)}
placeholder="유효기간 선택"
+ minDate={new Date()}
/>
</FormControl>
<FormMessage />
diff --git a/lib/pq/table/pq-lists-columns.tsx b/lib/pq/table/pq-lists-columns.tsx index 1c401fac..a9262a12 100644 --- a/lib/pq/table/pq-lists-columns.tsx +++ b/lib/pq/table/pq-lists-columns.tsx @@ -13,9 +13,19 @@ import { DropdownMenuTrigger
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
-import React from "react"
+import React, { useMemo, useState } from "react"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { Checkbox } from "@/components/ui/checkbox"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { DatePicker } from "@/components/ui/date-picker"
+import { toast } from "sonner"
export interface PQList {
id: number
@@ -48,6 +58,84 @@ const typeColors = { interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PQList> | null>>
}
+
+// 유효일 수정 시트 컴포넌트
+interface EditValidToSheetProps {
+ pqList: PQList | null
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onUpdate: (pqListId: number, newValidTo: Date | null) => Promise<void>
+}
+
+export function EditValidToSheet({ pqList, open, onOpenChange, onUpdate }: EditValidToSheetProps) {
+ const [newValidTo, setNewValidTo] = useState<Date | null>(pqList?.validTo || null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ const handleSave = async () => {
+ if (!pqList) return
+
+ setIsLoading(true)
+ try {
+ await onUpdate(pqList.id, newValidTo)
+ onOpenChange(false)
+ } catch (error) {
+ console.error("유효일 수정 실패:", error)
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent>
+ <SheetHeader>
+ <SheetTitle>유효일 수정</SheetTitle>
+ <SheetDescription>
+ {pqList && (
+ <>
+ <strong>{pqList.name}</strong>의 유효일을 수정합니다.
+ </>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="py-6">
+ <div className="space-y-4">
+ <div>
+ <label className="text-sm font-medium">현재 유효일</label>
+ <div className="mt-1 p-2 bg-muted rounded-md">
+ {pqList?.validTo ? formatDate(pqList.validTo, "ko-KR") : "설정되지 않음"}
+ </div>
+ </div>
+
+ <div>
+ <label className="text-sm font-medium">새 유효일</label>
+ <div className="mt-1">
+ <DatePicker
+ date={newValidTo ?? undefined}
+ onSelect={(date) => setNewValidTo(date ?? null)}
+ placeholder="새 유효일을 선택하세요"
+ minDate={new Date()}
+ />
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <SheetFooter>
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
+ 취소
+ </Button>
+ <Button onClick={handleSave} disabled={isLoading}>
+ {isLoading && <div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />}
+ 저장
+ </Button>
+ </SheetFooter>
+ </SheetContent>
+ </Sheet>
+ )
+}
+
export function createPQListsColumns({
setRowAction
}: GetColumnsProps): ColumnDef<PQList>[] {
@@ -122,17 +210,23 @@ export function createPQListsColumns({ ),
cell: ({ row }) => {
const validTo = row.getValue("validTo") as Date | null
- const now = new Date()
- const isExpired = validTo && validTo < now
-
- const formattedDate = validTo ? formatDate(validTo, "ko-KR") : "-"
-
+
+ const dateInfo = useMemo(() => {
+ if (!validTo) return { formattedDate: "-", isExpired: false }
+
+ const now = new Date()
+ const isExpired = validTo < now
+ const formattedDate = formatDate(validTo, "ko-KR")
+
+ return { formattedDate, isExpired }
+ }, [validTo])
+
return (
<div className="text-sm">
- <span className={isExpired ? "text-red-600 font-medium" : ""}>
- {formattedDate}
+ <span className={dateInfo.isExpired ? "text-red-600 font-medium" : ""}>
+ {dateInfo.formattedDate}
</span>
- {isExpired && (
+ {dateInfo.isExpired && (
<Badge variant="destructive" className="ml-2 text-xs">
만료
</Badge>
@@ -168,14 +262,20 @@ export function createPQListsColumns({ header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="생성일" />
),
- cell: ({ row }) => formatDate(row.getValue("createdAt"), "ko-KR"),
+ cell: ({ row }) => {
+ const createdAt = row.getValue("createdAt") as Date
+ return useMemo(() => formatDate(createdAt, "ko-KR"), [createdAt])
+ },
},
{
accessorKey: "updatedAt",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="변경일" />
),
- cell: ({ row }) => formatDate(row.getValue("updatedAt"), "ko-KR"),
+ cell: ({ row }) => {
+ const updatedAt = row.getValue("updatedAt") as Date
+ return useMemo(() => formatDate(updatedAt, "ko-KR"), [updatedAt])
+ },
},
{
id: "actions",
@@ -196,6 +296,11 @@ export function createPQListsColumns({ >
상세보기
</DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "editValidTo" })}
+ >
+ 유효일 수정
+ </DropdownMenuItem>
{/* <DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => setRowAction({ row, type: "delete" })}
diff --git a/lib/pq/table/pq-lists-table.tsx b/lib/pq/table/pq-lists-table.tsx index c5fd82a5..1be0a1c7 100644 --- a/lib/pq/table/pq-lists-table.tsx +++ b/lib/pq/table/pq-lists-table.tsx @@ -13,10 +13,12 @@ import { deletePQListsAction,
copyPQListAction,
togglePQListsAction,
+ updatePqValidToAction,
} from "@/lib/pq/service"
import { CopyPqDialog } from "./copy-pq-list-dialog"
import { AddPqDialog } from "./add-pq-list-dialog"
import { PQListsToolbarActions } from "./pq-lists-toolbar"
+import { EditValidToSheet } from "./pq-lists-columns"
import type { DataTableRowAction } from "@/types/table"
interface Project {
@@ -34,10 +36,12 @@ export function PqListsTable({ promises }: PqListsTableProps) { const [rowAction, setRowAction] = React.useState<DataTableRowAction<PQList> | null>(null)
const [createDialogOpen, setCreateDialogOpen] = React.useState(false)
const [copyDialogOpen, setCopyDialogOpen] = React.useState(false)
+ const [editValidToSheetOpen, setEditValidToSheetOpen] = React.useState(false)
+ const [selectedPqList, setSelectedPqList] = React.useState<PQList | null>(null)
const [isPending, startTransition] = React.useTransition()
const [{ data, pageCount }, projects] = React.use(promises)
- const activePqLists = data.filter((item) => !item.isDeleted)
+ // const activePqLists = data.filter((item) => !item.isDeleted)
const columns = React.useMemo(() => createPQListsColumns({ setRowAction }), [setRowAction])
@@ -116,15 +120,38 @@ export function PqListsTable({ promises }: PqListsTableProps) { })
}
+ const handleUpdateValidTo = React.useCallback(async (pqListId: number, newValidTo: Date | null) => {
+ startTransition(async () => {
+ try {
+ const result = await updatePqValidToAction({ pqListId, validTo: newValidTo })
+ if (result.success) {
+ toast.success(result.message || "유효일이 성공적으로 수정되었습니다")
+ setEditValidToSheetOpen(false)
+ setSelectedPqList(null)
+ router.refresh()
+ } else {
+ toast.error(`유효일 수정 실패: ${result.error}`)
+ }
+ } catch (error) {
+ console.error("유효일 수정 실패:", error)
+ toast.error("유효일 수정 실패")
+ }
+ })
+ }, [])
+
React.useEffect(() => {
if (!rowAction) return
- const id = rowAction.row.original.id
+ const pqList = rowAction.row.original
switch (rowAction.type) {
case "view":
- router.push(`/evcp/pq-criteria/${id}`)
+ router.push(`/evcp/pq-criteria/${pqList.id}`)
break
case "delete":
- handleDelete([id])
+ handleDelete([pqList.id])
+ break
+ case "editValidTo":
+ setSelectedPqList(pqList)
+ setEditValidToSheetOpen(true)
break
}
setRowAction(null)
@@ -153,18 +180,24 @@ export function PqListsTable({ promises }: PqListsTableProps) { open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onSubmit={handleCreate}
- projects={projects}
isLoading={isPending}
/>
<CopyPqDialog
open={copyDialogOpen}
onOpenChange={setCopyDialogOpen}
- pqLists={activePqLists}
+ pqLists={data}
projects={projects}
onCopy={handleCopy}
isLoading={isPending}
/>
+
+ <EditValidToSheet
+ pqList={selectedPqList}
+ open={editValidToSheetOpen}
+ onOpenChange={setEditValidToSheetOpen}
+ onUpdate={handleUpdateValidTo}
+ />
</>
)
}
diff --git a/lib/pq/table/pq-lists-toolbar.tsx b/lib/pq/table/pq-lists-toolbar.tsx index 3a85327d..1feb9a1a 100644 --- a/lib/pq/table/pq-lists-toolbar.tsx +++ b/lib/pq/table/pq-lists-toolbar.tsx @@ -2,7 +2,7 @@ import * as React from "react"
import { Button } from "@/components/ui/button"
-import { Trash, CopyPlus, Plus } from "lucide-react"
+import { Trash, CopyPlus, Plus, RefreshCw } from "lucide-react"
import { type Table } from "@tanstack/react-table"
import type { PQList } from "./pq-lists-columns"
// import { PqListForm } from "./add-pq-list-dialog"
@@ -44,7 +44,7 @@ export function PQListsToolbarActions({ size="sm"
onClick={() => onToggleActive(selected, newState!)}
>
- <Trash className="mr-2 h-4 w-4" />
+ <RefreshCw className="mr-2 h-4 w-4" />
{toggleLabel}
</Button>
)}
diff --git a/lib/pq/validations.ts b/lib/pq/validations.ts index 93daf460..c8a9d8e5 100644 --- a/lib/pq/validations.ts +++ b/lib/pq/validations.ts @@ -107,9 +107,7 @@ export const copyPqListSchema = z.object({ sourcePqListId: z.number({
required_error: "복사할 PQ 목록을 선택해주세요"
}),
- targetProjectId: z.number({
- required_error: "대상 프로젝트를 선택해주세요"
- }),
+ targetProjectId: z.number().optional(),
newName: z.string().min(1, "새 PQ 목록 명을 입력해주세요").optional(),
validTo: z.date().optional().nullable(),
});
@@ -124,4 +122,14 @@ export type CreatePqListInput = z.infer<typeof createPqListSchema>; export type CopyPqListInput = z.infer<typeof copyPqListSchema>;
export type GetPqListsSchema = z.infer<typeof getPqListsSchema>;
-export type GetPQSubmissionsSchema = Awaited<ReturnType<typeof searchParamsPQReviewCache.parse>>
\ No newline at end of file +export type GetPQSubmissionsSchema = Awaited<ReturnType<typeof searchParamsPQReviewCache.parse>>
+
+// PQ 유효일 수정
+export const updatePqValidToSchema = z.object({
+ pqListId: z.number({
+ required_error: "PQ 목록 ID가 필요합니다"
+ }),
+ validTo: z.date().nullable(),
+});
+
+export type UpdatePqValidToInput = z.infer<typeof updatePqValidToSchema>;
\ No newline at end of file diff --git a/lib/rfq-last/attachment/rfq-attachments-table.tsx b/lib/rfq-last/attachment/rfq-attachments-table.tsx index 09c9fe35..3098f8f5 100644 --- a/lib/rfq-last/attachment/rfq-attachments-table.tsx +++ b/lib/rfq-last/attachment/rfq-attachments-table.tsx @@ -412,9 +412,43 @@ export function RfqAttachmentsTable({ id: "actions", header: "작업", cell: ({ row }) => { + // 구매 탭에서만 작업 버튼 표시 + if (activeTab !== '구매') { + return <span className="text-muted-foreground text-sm">-</span>; + } + return ( <DropdownMenu> - {/* ... 기존 드롭다운 메뉴 내용 ... */} + <DropdownMenuTrigger asChild> + <Button variant="ghost" className="h-8 w-8 p-0"> + <span className="sr-only">메뉴 열기</span> + <svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M3.625 7.5C3.625 8.12132 3.12132 8.625 2.5 8.625C1.87868 8.625 1.375 8.12132 1.375 7.5C1.375 6.87868 1.87868 6.375 2.5 6.375C3.12132 6.375 3.625 6.87868 3.625 7.5ZM8.625 7.5C8.625 8.12132 8.12132 8.625 7.5 8.625C6.87868 8.625 6.375 8.12132 6.375 7.5C6.375 6.87868 6.87868 6.375 7.5 6.375C8.12132 6.375 8.625 6.87868 8.625 7.5ZM12.5 8.625C13.1213 8.625 13.625 8.12132 13.625 7.5C13.625 6.87868 13.1213 6.375 12.5 6.375C11.8787 6.375 11.375 6.87868 11.375 7.5C11.375 8.12132 11.8787 8.625 12.5 8.625Z" fill="currentColor" fillRule="evenodd" clipRule="evenodd"></path> + </svg> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + <DropdownMenuItem onClick={() => handleAction({ type: "download", row })}> + 다운로드 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => handleAction({ type: "preview", row })}> + 미리보기 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem onClick={() => handleAction({ type: "history", row })}> + 리비전 히스토리 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => handleAction({ type: "update", row })}> + 새 버전 업로드 + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onClick={() => handleAction({ type: "delete", row })} + className="text-red-600" + > + 삭제 + </DropdownMenuItem> + </DropdownMenuContent> </DropdownMenu> ); }, diff --git a/lib/rfq-last/vendor/rfq-vendor-table.tsx b/lib/rfq-last/vendor/rfq-vendor-table.tsx index b8a5184f..e5c1f51e 100644 --- a/lib/rfq-last/vendor/rfq-vendor-table.tsx +++ b/lib/rfq-last/vendor/rfq-vendor-table.tsx @@ -1581,7 +1581,7 @@ export function RfqVendorTable({ disabled={isLoadingSendData} > <Settings2 className="h-4 w-4 mr-2" /> - 정보 일괄 입력 ({selectedRows.length}) + 협력업체 조건 설정 ({selectedRows.length}) </Button> {/* RFQ 발송 버튼 */} diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index de88ae72..98c72349 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2,7 +2,7 @@ import { revalidatePath, revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendorPossibleMaterials, vendors, vendorsWithTypesView, vendorsWithTypesAndMaterialsView, vendorTypes, type Vendor } from "@/db/schema"; +import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorMaterialsView, vendorPossibleItems, vendorPossibleMaterials, vendors, vendorsWithTypesView, vendorsWithTypesAndMaterialsView, vendorTypes, type Vendor, pqLists } from "@/db/schema"; import logger from '@/lib/logger'; import * as z from "zod" import crypto from 'crypto'; @@ -2952,6 +2952,44 @@ export async function requestPQVendors(input: ApproveVendorsInput & { } } + // PQ 리스트 정보 조회 및 문항 검사 + const pqType = input.type || "GENERAL"; + const pqListConditions = [ + eq(pqLists.type, pqType), + eq(pqLists.isDeleted, false) + ]; + + if (input.projectId) { + pqListConditions.push(eq(pqLists.projectId, input.projectId)); + } else { + pqListConditions.push(isNull(pqLists.projectId)); + } + + const pqList = await db + .select() + .from(pqLists) + .where(and(...pqListConditions)) + .limit(1) + .then(rows => rows[0]); + + // PQ 리스트가 존재하지 않으면 요청 불가 + if (!pqList) { + return { + success: false, + error: input.projectId ? "프로젝트 PQ 리스트를 찾을 수 없습니다" : "일반 PQ 리스트를 찾을 수 없습니다" + }; + } + + // PQ 리스트에 문항이 있는지 확인 + const { getPqListCriteriaCount } = await import("@/lib/pq/service"); + const criteriaCount = await getPqListCriteriaCount(pqList.id); + if (criteriaCount === 0) { + return { + success: false, + error: "PQ 리스트에 문항이 없습니다. 문항을 추가한 후 요청해주세요" + }; + } + const result = await db.transaction(async (tx) => { const vendorsBeforeUpdate = await tx .select({ id: vendors.id, status: vendors.status }) diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx index fd6da145..b5e3b8a8 100644 --- a/lib/vendors/table/request-pq-dialog.tsx +++ b/lib/vendors/table/request-pq-dialog.tsx @@ -709,27 +709,15 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro date={dueDate ? new Date(dueDate) : undefined}
onSelect={(date?: Date) => {
if (date) {
- // 현재 날짜 기준으로 이전 날짜는 선택 불가능
- const today = new Date()
- today.setHours(0, 0, 0, 0) // 오늘 날짜의 시작 시간으로 설정
-
- const selectedDate = new Date(date)
- selectedDate.setHours(0, 0, 0, 0) // 선택된 날짜의 시작 시간으로 설정
-
- if (selectedDate < today) {
- toast.error("마감일은 오늘 날짜 이후로 선택해주세요.")
- return
- } else {
-
// 한국 시간대로 날짜 변환 (UTC 변환으로 인한 날짜 변경 방지)
const kstDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
setDueDate(kstDate.toISOString().slice(0, 10))
- }
} else {
setDueDate("")
}
}}
placeholder="마감일 선택"
+ minDate={new Date()}
/>
</div>
|
