summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-08-19 09:23:47 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-08-19 09:23:47 +0000
commit8077419e40368dc703f94d558fc746b73fbc6702 (patch)
tree333bdfb3b0d84336f1bf7d4f0f1bbced6bec2d4c /lib
parentaa71f75ace013b2fe982e5a104e61440458e0fd2 (diff)
(최겸) 구매 PQ 비밀유지계약서 별첨 첨부파일 추가, 정규업체등록관리 개발
Diffstat (limited to 'lib')
-rw-r--r--lib/risk-management/table/risks-mail-dialog.tsx10
-rw-r--r--lib/vendor-registration-status/vendor-registration-status-view.tsx300
-rw-r--r--lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx143
-rw-r--r--lib/vendor-regular-registrations/service.ts62
-rw-r--r--lib/vendor-regular-registrations/table/major-items-update-dialog.tsx (renamed from lib/vendor-regular-registrations/major-items-update-sheet.tsx)38
-rw-r--r--lib/vendor-regular-registrations/table/safety-qualification-update-dialog.tsx (renamed from lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx)36
-rw-r--r--lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx22
-rw-r--r--lib/vendors/service.ts111
-rw-r--r--lib/vendors/table/request-pq-dialog.tsx148
9 files changed, 349 insertions, 521 deletions
diff --git a/lib/risk-management/table/risks-mail-dialog.tsx b/lib/risk-management/table/risks-mail-dialog.tsx
index 8bee1191..02c470ce 100644
--- a/lib/risk-management/table/risks-mail-dialog.tsx
+++ b/lib/risk-management/table/risks-mail-dialog.tsx
@@ -176,7 +176,7 @@ function RisksMailDialog(props: RisksMailDialogProps) {
setManagerList(managerList);
} catch (error) {
console.error('Error in Loading Risk Event for Managing:', error);
- toast.error(error instanceof Error ? error.message : '구매 담당자 목록을 불러오는 데 실패했어요.');
+ toast.error(error instanceof Error ? error.message : '구매 담당자 목록을 불러오는 데 실패했습니다.');
} finally {
setIsLoadingManagerList(false);
}
@@ -210,7 +210,7 @@ function RisksMailDialog(props: RisksMailDialogProps) {
const file = files[0];
const maxFileSize = 10 * 1024 * 1024
if (file.size > maxFileSize) {
- toast.error('파일 크기는 10MB를 초과할 수 없어요.');
+ toast.error('파일 크기는 10MB를 초과할 수 없습니다.');
return;
}
form.setValue('attachment', file);
@@ -272,15 +272,15 @@ function RisksMailDialog(props: RisksMailDialogProps) {
if (!res.ok) {
const errorData = await res.json();
- throw new Error(errorData.message || '리스크 알림 메일 전송에 실패했어요.');
+ throw new Error(errorData.message || '리스크 알림 메일 전송에 실패했습니다.');
}
- toast.success('리스크 알림 메일이 구매 담당자에게 발송되었어요.');
+ toast.success('리스크 알림 메일이 구매 담당자에게 발송되었습니다.');
onSuccess();
} catch (error) {
console.error('Error in Saving Risk Event:', error);
toast.error(
- error instanceof Error ? error.message : '리스크 알림 메일 발송 중 오류가 발생했어요.',
+ error instanceof Error ? error.message : '리스크 알림 메일 발송 중 오류가 발생했습니다.',
);
}
})
diff --git a/lib/vendor-registration-status/vendor-registration-status-view.tsx b/lib/vendor-registration-status/vendor-registration-status-view.tsx
deleted file mode 100644
index 850dd777..00000000
--- a/lib/vendor-registration-status/vendor-registration-status-view.tsx
+++ /dev/null
@@ -1,300 +0,0 @@
-"use client"
-
-import { useState, useEffect } from "react"
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { Button } from "@/components/ui/button"
-
-import {
- CheckCircle,
- XCircle,
- FileText,
- AlertCircle,
- Eye,
- Upload
-} from "lucide-react"
-import { DocumentStatusDialog } from "@/components/vendor-regular-registrations/document-status-dialog"
-import { AdditionalInfoDialog } from "@/components/vendor-regular-registrations/additional-info-dialog"
-import { format } from "date-fns"
-import { toast } from "sonner"
-import { fetchVendorRegistrationStatus } from "@/lib/vendor-regular-registrations/service"
-
-// 세션에서 벤더아이디 가져오기 위한 훅
-import { useSession } from "next-auth/react"
-
-// 상태별 정의
-const statusConfig = {
- audit_pass: {
- label: "실사통과",
- color: "bg-blue-100 text-blue-800",
- description: "품질담당자(QM) 최종 의견에 따라 실사 통과로 결정된 상태"
- },
- cp_submitted: {
- label: "CP등록",
- color: "bg-green-100 text-green-800",
- description: "협력업체에서 실사 통과 후 기본계약문서에 대한 답변 제출/서약 완료한 상태"
- },
- cp_review: {
- label: "CP검토",
- color: "bg-yellow-100 text-yellow-800",
- description: "협력업체에서 제출한 CP/GTC에 대한 법무검토 의뢰한 상태"
- },
- cp_finished: {
- label: "CP완료",
- color: "bg-purple-100 text-purple-800",
- description: "CP 답변에 대한 법무검토 완료되어 정규업체 등록 가능한 상태"
- },
- approval_ready: {
- label: "조건충족",
- color: "bg-emerald-100 text-emerald-800",
- description: "정규업체 등록 문서/자료 접수현황에 누락이 없는 상태"
- },
- in_review: {
- label: "정규등록검토",
- color: "bg-orange-100 text-orange-800",
- description: "구매담당자 요청에 따라 정규업체 등록 관리자가 정규업체 등록 가능여부 검토"
- },
- pending_approval: {
- label: "장기미등록",
- color: "bg-red-100 text-red-800",
- description: "정규업체로 등록 요청되어 3개월 이내 정규업체 등록되지 않은 상태"
- }
-}
-
-// 필수문서 목록
-const requiredDocuments = [
- { key: "businessRegistration", label: "사업자등록증" },
- { key: "creditEvaluation", label: "신용평가서" },
- { key: "bankCopy", label: "통장사본" },
- { key: "cpDocument", label: "CP문서" },
- { key: "gtc", label: "GTC" },
- { key: "standardSubcontract", label: "표준하도급" },
- { key: "safetyHealth", label: "안전보건관리" },
- { key: "ethics", label: "윤리규범준수" },
- { key: "domesticCredit", label: "내국신용장" },
- { key: "safetyQualification", label: "안전적격성평가" },
-]
-
-export function VendorRegistrationStatusView() {
- const [additionalInfoDialogOpen, setAdditionalInfoDialogOpen] = useState(false)
- const [documentDialogOpen, setDocumentDialogOpen] = useState(false)
- const [hasSignature, setHasSignature] = useState(false)
- const [data, setData] = useState<any>(null)
- const [loading, setLoading] = useState(true)
-
- // 세션에서 vendorId 가져오기
- const { data: session, status: sessionStatus } = useSession()
- const vendorId = session?.user?.companyId
- console.log(vendorId)
-
- // 데이터 로드
- useEffect(() => {
- if (!vendorId) return
-
- const initialLoad = async () => {
- try {
- const result = await fetchVendorRegistrationStatus(vendorId)
- if (result.success) {
- setData(result.data)
- } else {
- toast.error(result.error)
- }
- } catch {
- toast.error("데이터 로드 중 오류가 발생했습니다.")
- } finally {
- setLoading(false)
- }
- }
-
- initialLoad()
- }, [vendorId])
-
- if (sessionStatus === "loading" || loading) {
- return <div className="p-8 text-center">로딩 중...</div>
- }
-
- if (!vendorId) {
- return <div className="p-8 text-center">벤더 정보가 없습니다. 다시 로그인 해주세요.</div>
- }
-
- if (!data) {
- return <div className="p-8 text-center">데이터를 불러올 수 없습니다.</div>
- }
-
- const currentStatusConfig = statusConfig[data.registration?.status as keyof typeof statusConfig] || statusConfig.audit_pass
-
- // 미완성 항목 계산
- const missingDocuments = requiredDocuments.filter(
- doc => !data.documentStatus[doc.key as keyof typeof data.documentStatus]
- )
-
- // Document Status Dialog에 전달할 registration 데이터 구성
- const registrationForDialog: any = {
- id: data.registration?.id || 0,
- vendorId: data.vendor.id,
- companyName: data.vendor.vendorName,
- businessNumber: data.vendor.taxId,
- representative: data.vendor.representativeName || "",
- country: data.vendor.country || "KR", // 기본값 KR
- potentialCode: data.registration?.potentialCode || "",
- status: data.registration?.status || "audit_pass",
- majorItems: "[]", // 빈 JSON 문자열
- establishmentDate: data.vendor.createdAt || new Date(),
- registrationRequestDate: data.registration?.registrationRequestDate,
- assignedDepartment: data.registration?.assignedDepartment,
- assignedDepartmentCode: data.registration?.assignedDepartmentCode,
- assignedUser: data.registration?.assignedUser,
- assignedUserCode: data.registration?.assignedUserCode,
- remarks: data.registration?.remarks,
- safetyQualificationContent: data.registration?.safetyQualificationContent || null,
- gtcSkipped: data.registration?.gtcSkipped || false,
- additionalInfo: data.additionalInfo,
- documentSubmissions: data.documentStatus, // documentSubmissions를 documentStatus로 설정
- contractAgreements: [],
- basicContracts: data.basicContracts || [], // 실제 데이터 사용
- documentSubmissionsStatus: data.documentStatus,
- contractAgreementsStatus: {
- cpDocument: data.documentStatus.cpDocument,
- gtc: data.documentStatus.gtc,
- standardSubcontract: data.documentStatus.standardSubcontract,
- safetyHealth: data.documentStatus.safetyHealth,
- ethics: data.documentStatus.ethics,
- domesticCredit: data.documentStatus.domesticCredit,
- },
- createdAt: data.registration?.createdAt || new Date(),
- updatedAt: data.registration?.updatedAt || new Date(),
- }
-
- const handleSignatureUpload = () => {
- // TODO: 서명/직인 업로드 기능 구현
- setHasSignature(true)
- toast.success("서명/직인이 등록되었습니다.")
- }
-
- const handleAdditionalInfoSave = () => {
- // 데이터 새로고침
- loadData()
- }
-
- const loadData = async () => {
- if (!vendorId) return
- try {
- const result = await fetchVendorRegistrationStatus(vendorId)
- if (result.success) {
- setData(result.data)
- toast.success("데이터가 새로고침되었습니다.")
- } else {
- toast.error(result.error)
- }
- } catch {
- toast.error("데이터 로드 중 오류가 발생했습니다.")
- }
- }
-
- return (
- <div className="space-y-6">
- {/* 헤더 섹션 */}
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <div>
- <h1 className="text-3xl font-bold">정규업체 등록관리 현황</h1>
- <p className="text-muted-foreground">
- {data.registration?.potentialCode || "미등록"} | {data.vendor.companyName}
- </p>
- <p className="text-sm text-muted-foreground mt-1">
- 정규업체 등록 진행현황을 확인하세요.
- </p>
- </div>
- <Badge className={currentStatusConfig.color} variant="secondary">
- {currentStatusConfig.label}
- </Badge>
- </div>
- </div>
-
- {/* 회사 서명/직인 등록 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="w-5 h-5" />
- 회사 서명/직인 등록
- <Badge variant="destructive" className="text-xs">필수</Badge>
- </CardTitle>
- </CardHeader>
- <CardContent>
- {hasSignature ? (
- <div className="flex items-center gap-3 p-4 border rounded-lg bg-green-50">
- <CheckCircle className="w-5 h-5 text-green-600" />
- <span className="text-green-800">서명/직인이 등록되었습니다.</span>
- </div>
- ) : (
- <Button
- onClick={handleSignatureUpload}
- className="w-full h-20 border-2 border-dashed border-muted-foreground/25 bg-muted/25"
- variant="outline"
- >
- <div className="text-center">
- <Upload className="w-6 h-6 mx-auto mb-2" />
- <span>서명/직인 등록하기</span>
- </div>
- </Button>
- )}
- </CardContent>
- </Card>
-
- {/* 간소화된 액션 버튼들 */}
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
- <Button
- onClick={() => setDocumentDialogOpen(true)}
- variant="outline"
- size="lg"
- className="h-16 flex flex-col items-center gap-2"
- >
- <Eye className="w-6 h-6" />
- <span>문서 현황 확인</span>
- </Button>
- <Button
- onClick={() => setAdditionalInfoDialogOpen(true)}
- variant={data.additionalInfo ? "outline" : "default"}
- size="lg"
- className="h-16 flex flex-col items-center gap-2"
- >
- <FileText className="w-6 h-6" />
- <span>{data.additionalInfo ? "추가정보 수정" : "추가정보 등록"}</span>
- </Button>
- </div>
-
- {/* 상태 설명 */}
- <Card>
- <CardHeader>
- <CardTitle>현재 상태 안내</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="flex items-start gap-3">
- <Badge className={currentStatusConfig.color} variant="secondary">
- {currentStatusConfig.label}
- </Badge>
- <p className="text-sm text-muted-foreground">
- {currentStatusConfig.description}
- </p>
- </div>
- </CardContent>
- </Card>
-
- {/* 문서 현황 Dialog */}
- <DocumentStatusDialog
- open={documentDialogOpen}
- onOpenChange={setDocumentDialogOpen}
- registration={registrationForDialog}
- onRefresh={loadData}
- />
-
- {/* 추가정보 입력 Dialog */}
- <AdditionalInfoDialog
- open={additionalInfoDialogOpen}
- onOpenChange={setAdditionalInfoDialogOpen}
- vendorId={vendorId}
- onSave={handleAdditionalInfoSave}
- />
- </div>
- )
-}
diff --git a/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx b/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx
deleted file mode 100644
index a93fbf22..00000000
--- a/lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx
+++ /dev/null
@@ -1,143 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { z } from "zod"
-import { toast } from "sonner"
-
-import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Button } from "@/components/ui/button"
-import { Textarea } from "@/components/ui/textarea"
-import { updateSafetyQualification } from "./service"
-
-const formSchema = z.object({
- safetyQualificationContent: z.string().min(1, "안전적격성 평가 내용을 입력해주세요."),
-})
-
-interface SafetyQualificationUpdateSheetProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- registrationId?: number
- vendorName?: string
- currentContent?: string | null
- onSuccess?: () => void
-}
-
-export function SafetyQualificationUpdateSheet({
- open,
- onOpenChange,
- registrationId,
- vendorName,
- currentContent,
- onSuccess,
-}: SafetyQualificationUpdateSheetProps) {
- const [isLoading, setIsLoading] = React.useState(false)
-
- const form = useForm<z.infer<typeof formSchema>>({
- resolver: zodResolver(formSchema),
- defaultValues: {
- safetyQualificationContent: currentContent || "",
- },
- })
-
- // 폼 값 초기화
- React.useEffect(() => {
- if (open) {
- form.reset({
- safetyQualificationContent: currentContent || "",
- })
- }
- }, [open, currentContent, form])
-
- async function onSubmit(values: z.infer<typeof formSchema>) {
- if (!registrationId) {
- toast.error("등록 ID가 없습니다.")
- return
- }
-
- setIsLoading(true)
- try {
- const result = await updateSafetyQualification(
- registrationId,
- values.safetyQualificationContent
- )
-
- if (result.success) {
- toast.success("안전적격성 평가가 등록되었습니다.")
- onOpenChange(false)
- onSuccess?.()
- } else {
- toast.error(result.error || "안전적격성 평가 등록에 실패했습니다.")
- }
- } catch (error) {
- console.error("안전적격성 평가 등록 오류:", error)
- toast.error("안전적격성 평가 등록 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }
-
- return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="w-[400px] sm:w-[540px]">
- <SheetHeader>
- <SheetTitle>안전적격성 평가 입력</SheetTitle>
- <SheetDescription>
- {vendorName && `${vendorName}의 `}안전적격성 평가 내용을 입력해주세요.
- </SheetDescription>
- </SheetHeader>
-
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 mt-6">
- <FormField
- control={form.control}
- name="safetyQualificationContent"
- render={({ field }) => (
- <FormItem>
- <FormLabel>안전적격성 평가 내용</FormLabel>
- <FormControl>
- <Textarea
- placeholder="안전적격성 평가 결과 및 내용을 입력해주세요..."
- className="min-h-[200px]"
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- <div className="flex justify-end space-x-2">
- <Button
- type="button"
- variant="outline"
- onClick={() => onOpenChange(false)}
- disabled={isLoading}
- >
- 취소
- </Button>
- <Button type="submit" disabled={isLoading}>
- {isLoading ? "저장 중..." : "저장"}
- </Button>
- </div>
- </form>
- </Form>
- </SheetContent>
- </Sheet>
- )
-}
diff --git a/lib/vendor-regular-registrations/service.ts b/lib/vendor-regular-registrations/service.ts
index 51f4e82b..d64c7b8b 100644
--- a/lib/vendor-regular-registrations/service.ts
+++ b/lib/vendor-regular-registrations/service.ts
@@ -25,7 +25,46 @@ import {
basicContractTemplates
} from "@/db/schema";
import db from "@/db/db";
-import { inArray, eq, desc } from "drizzle-orm";
+import { inArray, eq, desc, and, lt } from "drizzle-orm";
+
+// 3개월 이상 정규등록검토 상태인 등록을 장기미등록으로 변경
+async function updatePendingApprovals() {
+ try {
+ const threeMonthsAgo = new Date();
+ threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
+
+ // 3개월 이상 정규등록검토 상태인 등록들을 조회
+ const outdatedRegistrations = await db
+ .select()
+ .from(vendorRegularRegistrations)
+ .where(
+ and(
+ eq(vendorRegularRegistrations.status, "in_review"),
+ lt(vendorRegularRegistrations.updatedAt, threeMonthsAgo)
+ )
+ );
+
+ // 장기미등록으로 상태 변경
+ if (outdatedRegistrations.length > 0) {
+ await db
+ .update(vendorRegularRegistrations)
+ .set({
+ status: "pending_approval",
+ updatedAt: new Date(),
+ })
+ .where(
+ and(
+ eq(vendorRegularRegistrations.status, "in_review"),
+ lt(vendorRegularRegistrations.updatedAt, threeMonthsAgo)
+ )
+ );
+
+ console.log(`${outdatedRegistrations.length}개의 등록이 장기미등록으로 변경되었습니다.`);
+ }
+ } catch (error) {
+ console.error("장기미등록 상태 업데이트 오류:", error);
+ }
+}
// 캐싱과 에러 핸들링이 포함된 조회 함수
export async function fetchVendorRegularRegistrations(input?: {
@@ -37,6 +76,9 @@ export async function fetchVendorRegularRegistrations(input?: {
return unstable_cache(
async () => {
try {
+ // 장기미등록 상태 업데이트 실행
+ await updatePendingApprovals();
+
const registrations = await getVendorRegularRegistrations();
let filteredData = registrations;
@@ -113,6 +155,7 @@ export async function createVendorRegistration(data: {
const registration = await createVendorRegularRegistration({
...data,
+ status: data.status || "under_review", // 기본 상태를 '검토중'으로 설정
majorItems: majorItemsJson,
});
@@ -438,7 +481,7 @@ export async function skipLegalReview(vendorIds: number[], skipReason: string) {
// 새로 생성
const newRegistration = await createVendorRegularRegistration({
vendorId: vendorId,
- status: "cp_finished", // CP완료로 변경
+ status: "under_review", // 검토중으로 변경
remarks: `GTC Skip: ${skipReason}`,
});
registrationId = newRegistration.id;
@@ -451,7 +494,6 @@ export async function skipLegalReview(vendorIds: number[], skipReason: string) {
: `GTC Skip: ${skipReason}`;
await updateVendorRegularRegistration(registrationId, {
- status: "cp_finished", // CP완료로 변경
gtcSkipped: true, // GTC Skip 여부 설정
remarks: newRemarks,
});
@@ -630,7 +672,7 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
}
}
- // 정규업체 등록 정보
+ // 정규업체 등록 정보 (없을 수도 있음 - 기존 정규업체이거나 아직 등록 진행 안함)
const registration = await db
.select({
id: vendorRegularRegistrations.id,
@@ -653,6 +695,15 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
.where(eq(vendorRegularRegistrations.vendorId, vendorId))
.limit(1)
+ // 정규업체 등록 정보가 없는 경우 (정상적인 상황)
+ if (!registration[0]) {
+ return {
+ success: false,
+ error: "정규업체 등록 진행 정보가 없습니다.", // 에러가 아닌 정보성 메시지
+ noRegistration: true // 등록 정보가 없음을 명시적으로 표시
+ }
+ }
+
// 벤더 첨부파일 조회
const vendorFiles = await db
.select()
@@ -783,7 +834,8 @@ export async function fetchVendorRegistrationStatus(vendorId: number) {
missingDocuments,
businessContacts,
missingContactTypes,
- additionalInfo: additionalInfoCompleted, // boolean 값으로 변경
+ additionalInfo: additionalInfo[0] || null, // 실제 추가정보 데이터 반환
+ additionalInfoCompleted, // 완료 여부는 별도 필드로 추가
pqSubmission: pqSubmission[0] || null,
auditPassed: investigationFiles.length > 0,
basicContracts: vendorContracts, // 기본계약 정보 추가
diff --git a/lib/vendor-regular-registrations/major-items-update-sheet.tsx b/lib/vendor-regular-registrations/table/major-items-update-dialog.tsx
index ba125bbe..26741a1b 100644
--- a/lib/vendor-regular-registrations/major-items-update-sheet.tsx
+++ b/lib/vendor-regular-registrations/table/major-items-update-dialog.tsx
@@ -6,18 +6,18 @@ import { toast } from "sonner"
import { X, Plus, Search } from "lucide-react"
import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge"
import { Label } from "@/components/ui/label"
import { searchItemsForPQ } from "@/lib/items/service"
-import { updateMajorItems } from "./service"
+import { updateMajorItems } from "../service"
// PQ 대상 품목 타입 정의
interface PQItem {
@@ -25,7 +25,7 @@ interface PQItem {
itemName: string
}
-interface MajorItemsUpdateSheetProps {
+interface MajorItemsUpdateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
registrationId?: number
@@ -34,14 +34,14 @@ interface MajorItemsUpdateSheetProps {
onSuccess?: () => void
}
-export function MajorItemsUpdateSheet({
+export function MajorItemsUpdateDialog({
open,
onOpenChange,
registrationId,
vendorName,
currentItems,
onSuccess,
-}: MajorItemsUpdateSheetProps) {
+}: MajorItemsUpdateDialogProps) {
const [isLoading, setIsLoading] = useState(false)
const [selectedItems, setSelectedItems] = useState<PQItem[]>([])
@@ -144,14 +144,14 @@ export function MajorItemsUpdateSheet({
}
return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="w-[400px] sm:w-[540px]">
- <SheetHeader>
- <SheetTitle>주요품목 등록</SheetTitle>
- <SheetDescription>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-[400px] sm:w-[540px] max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>주요품목 등록</DialogTitle>
+ <DialogDescription>
{vendorName && `${vendorName}의 `}주요품목을 등록해주세요.
- </SheetDescription>
- </SheetHeader>
+ </DialogDescription>
+ </DialogHeader>
<div className="space-y-6 mt-6">
{/* 선택된 아이템들 표시 */}
@@ -239,7 +239,7 @@ export function MajorItemsUpdateSheet({
{isLoading ? "저장 중..." : "저장"}
</Button>
</div>
- </SheetContent>
- </Sheet>
+ </DialogContent>
+ </Dialog>
)
}
diff --git a/lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx b/lib/vendor-regular-registrations/table/safety-qualification-update-dialog.tsx
index c2aeba70..80084732 100644
--- a/lib/vendor-regular-registrations/table/safety-qualification-update-sheet.tsx
+++ b/lib/vendor-regular-registrations/table/safety-qualification-update-dialog.tsx
@@ -7,12 +7,12 @@ import { z } from "zod"
import { toast } from "sonner"
import {
- Sheet,
- SheetContent,
- SheetDescription,
- SheetHeader,
- SheetTitle,
-} from "@/components/ui/sheet"
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
import {
Form,
FormControl,
@@ -29,7 +29,7 @@ const formSchema = z.object({
safetyQualificationContent: z.string().min(1, "안전적격성 평가 내용을 입력해주세요."),
})
-interface SafetyQualificationUpdateSheetProps {
+interface SafetyQualificationUpdateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
registrationId?: number
@@ -38,14 +38,14 @@ interface SafetyQualificationUpdateSheetProps {
onSuccess?: () => void
}
-export function SafetyQualificationUpdateSheet({
+export function SafetyQualificationUpdateDialog({
open,
onOpenChange,
registrationId,
vendorName,
currentContent,
onSuccess,
-}: SafetyQualificationUpdateSheetProps) {
+}: SafetyQualificationUpdateDialogProps) {
const [isLoading, setIsLoading] = React.useState(false)
const form = useForm<z.infer<typeof formSchema>>({
@@ -93,14 +93,14 @@ export function SafetyQualificationUpdateSheet({
}
return (
- <Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="w-[400px] sm:w-[540px]">
- <SheetHeader>
- <SheetTitle>안전적격성 평가 입력</SheetTitle>
- <SheetDescription>
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="w-[400px] sm:w-[540px] max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>안전적격성 평가 입력</DialogTitle>
+ <DialogDescription>
{vendorName && `${vendorName}의 `}안전적격성 평가 내용을 입력해주세요.
- </SheetDescription>
- </SheetHeader>
+ </DialogDescription>
+ </DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 mt-6">
@@ -137,7 +137,7 @@ export function SafetyQualificationUpdateSheet({
</div>
</form>
</Form>
- </SheetContent>
- </Sheet>
+ </DialogContent>
+ </Dialog>
)
}
diff --git a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
index 765b0279..7446716b 100644
--- a/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
+++ b/lib/vendor-regular-registrations/table/vendor-regular-registrations-table-columns.tsx
@@ -13,29 +13,23 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { Eye, FileText, Ellipsis, Shield, Package } from "lucide-react"
import { toast } from "sonner"
import { useState } from "react"
-import { SafetyQualificationUpdateSheet } from "./safety-qualification-update-sheet"
-import { MajorItemsUpdateSheet } from "../major-items-update-sheet"
+import { SafetyQualificationUpdateDialog } from "./safety-qualification-update-dialog"
+import { MajorItemsUpdateDialog } from "./major-items-update-dialog"
const statusLabels = {
- audit_pass: "실사통과",
- cp_submitted: "CP등록",
- cp_review: "CP검토",
- cp_finished: "CP완료",
+ under_review: "검토중",
approval_ready: "조건충족",
- registration_requested: "등록요청됨",
in_review: "정규등록검토",
+ completed: "등록완료",
pending_approval: "장기미등록",
}
const statusColors = {
- audit_pass: "bg-blue-100 text-blue-800",
- cp_submitted: "bg-green-100 text-green-800",
- cp_review: "bg-yellow-100 text-yellow-800",
- cp_finished: "bg-purple-100 text-purple-800",
+ under_review: "bg-blue-100 text-blue-800",
approval_ready: "bg-emerald-100 text-emerald-800",
- registration_requested: "bg-indigo-100 text-indigo-800",
in_review: "bg-orange-100 text-orange-800",
+ completed: "bg-green-100 text-green-800",
pending_approval: "bg-red-100 text-red-800",
}
@@ -295,7 +289,7 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
</DropdownMenuContent>
</DropdownMenu>
- <SafetyQualificationUpdateSheet
+ <SafetyQualificationUpdateDialog
open={safetyQualificationSheetOpen}
onOpenChange={setSafetyQualificationSheetOpen}
registrationId={registration.id}
@@ -306,7 +300,7 @@ export function getColumns(): ColumnDef<VendorRegularRegistration>[] {
window.location.reload()
}}
/>
- <MajorItemsUpdateSheet
+ <MajorItemsUpdateDialog
open={majorItemsSheetOpen}
onOpenChange={setMajorItemsSheetOpen}
registrationId={registration.id}
diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts
index 2a927069..9af81021 100644
--- a/lib/vendors/service.ts
+++ b/lib/vendors/service.ts
@@ -9,6 +9,8 @@ import crypto from 'crypto';
import fs from 'fs/promises';
import path from 'path';
import { v4 as uuidv4 } from 'uuid';
+import { saveDRMFile } from "@/lib/file-stroage";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
@@ -2002,6 +2004,7 @@ export async function getVendorDetailById(id: number) {
if (!vendor) {
return null;
}
+ console.log("vendor", vendor.attachments)
// JSON 문자열로 반환된 contacts와 attachments를 JavaScript 객체로 파싱
const contacts = typeof vendor.contacts === 'string'
@@ -2059,8 +2062,10 @@ export async function updateVendorInfo(params: {
files?: File[]
creditRatingFiles?: File[]
cashFlowRatingFiles?: File[]
+ signatureFiles?: File[] // 서명/직인 파일들
contacts: ContactInfo[]
filesToDelete?: number[] // 삭제할 파일 ID 목록
+ selectedAttachmentType?: string // 선택된 첨부파일 타입
}) {
try {
const {
@@ -2068,8 +2073,10 @@ export async function updateVendorInfo(params: {
files = [],
creditRatingFiles = [],
cashFlowRatingFiles = [],
+ signatureFiles = [],
contacts,
- filesToDelete = []
+ filesToDelete = [],
+ selectedAttachmentType = "GENERAL"
} = params
// 세션 및 권한 확인
@@ -2204,9 +2211,9 @@ export async function updateVendorInfo(params: {
}
// 4. 새 파일 저장 (제공된 storeVendorFiles 함수 활용)
- // 4-1. 일반 파일 저장
+ // 4-1. 일반 파일 저장 (선택된 타입 사용)
if (files.length > 0) {
- await storeVendorFiles(tx, vendorData.id, files, "GENERAL");
+ await storeVendorFiles(tx, vendorData.id, files, selectedAttachmentType);
}
// 4-2. 신용평가 파일 저장
@@ -2218,6 +2225,11 @@ export async function updateVendorInfo(params: {
if (cashFlowRatingFiles.length > 0) {
await storeVendorFiles(tx, vendorData.id, cashFlowRatingFiles, "CASH_FLOW_RATING");
}
+
+ // 4-4. 서명/직인 파일 저장
+ if (signatureFiles.length > 0) {
+ await storeVendorFiles(tx, vendorData.id, signatureFiles, "SIGNATURE");
+ }
})
// 캐시 무효화
@@ -2989,3 +3001,96 @@ export async function requestBasicContractInfo({
};
}
}
+
+/**
+ * 비밀유지 계약서 첨부파일 저장 서버 액션
+ */
+export async function saveNdaAttachments(input: {
+ vendorIds: number[];
+ files: File[];
+ userId: string;
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("📎 비밀유지 계약서 첨부파일 저장 시작");
+ console.log(`벤더 수: ${input.vendorIds.length}, 파일 수: ${input.files.length}`);
+
+ const results = [];
+
+ for (const vendorId of input.vendorIds) {
+ for (const file of input.files) {
+ console.log(`📄 처리 중: 벤더 ID ${vendorId} - ${file.name}`);
+
+ try {
+ // saveDRMFile을 사용해서 파일 저장
+ const saveResult = await saveDRMFile(
+ file,
+ decryptWithServerAction,
+ `vendor-attachments/nda/${vendorId}`,
+ input.userId
+ );
+
+ if (!saveResult.success) {
+ throw new Error(`파일 저장 실패: ${file.name} - ${saveResult.error}`);
+ }
+
+ // vendor_attachments 테이블에 파일 정보 저장
+ const insertedAttachment = await db.insert(vendorAttachments).values({
+ vendorId: vendorId,
+ fileType: file.type || 'application/octet-stream',
+ fileName: saveResult.fileName || file.name,
+ filePath: saveResult.publicPath || '',
+ attachmentType: 'NDA_ATTACHMENT',
+ }).returning();
+
+ results.push({
+ vendorId,
+ fileName: file.name,
+ attachmentId: insertedAttachment[0]?.id || 0,
+ success: true
+ });
+
+ console.log(`✅ 완료: 벤더 ID ${vendorId} - ${file.name}`);
+
+ } catch (error) {
+ console.error(`❌ 실패: 벤더 ID ${vendorId} - ${file.name}`, error);
+ results.push({
+ vendorId,
+ fileName: file.name,
+ success: false,
+ error: error instanceof Error ? error.message : '알 수 없는 오류'
+ });
+ }
+ }
+ }
+
+ // 성공/실패 카운트
+ const successCount = results.filter(r => r.success).length;
+ const failureCount = results.filter(r => !r.success).length;
+
+ console.log(`📊 처리 결과: 성공 ${successCount}개, 실패 ${failureCount}개`);
+
+ // 캐시 무효화
+ revalidateTag("vendor-attachments");
+
+ return {
+ success: true,
+ results,
+ summary: {
+ total: results.length,
+ success: successCount,
+ failure: failureCount
+ }
+ };
+
+ } catch (error) {
+ console.error("비밀유지 계약서 첨부파일 저장 중 오류 발생:", error);
+ return {
+ success: false,
+ error: error instanceof Error
+ ? error.message
+ : "첨부파일 저장 처리 중 오류가 발생했습니다."
+ };
+ }
+}
diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx
index 5b5f722c..9fd7b1d8 100644
--- a/lib/vendors/table/request-pq-dialog.tsx
+++ b/lib/vendors/table/request-pq-dialog.tsx
@@ -46,6 +46,7 @@ import { DatePicker } from "@/components/ui/date-picker"
import { getALLBasicContractTemplates } from "@/lib/basic-contract/service"
import type { BasicContractTemplate } from "@/db/schema"
import { searchItemsForPQ } from "@/lib/items/service"
+import { saveNdaAttachments } from "../service"
// import { PQContractViewer } from "../pq-contract-viewer" // 더 이상 사용하지 않음
interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
@@ -54,16 +55,16 @@ interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dia
onSuccess?: () => void
}
-const AGREEMENT_LIST = [
- "준법서약",
- "표준하도급계약",
- "안전보건관리계약",
- "윤리규범 준수 서약",
- "동반성장협약",
- "내국신용장 미개설 합의",
- "기술자료 제출 기본 동의",
- "GTC 합의",
-]
+// const AGREEMENT_LIST = [
+// "준법서약",
+// "표준하도급계약",
+// "안전보건관리계약",
+// "윤리규범 준수 서약",
+// "동반성장협약",
+// "내국신용장 미개설 합의",
+// "기술자료 제출 기본 동의",
+// "GTC 합의",
+// ]
// PQ 대상 품목 타입 정의
interface PQItem {
@@ -92,6 +93,10 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
const [basicContractTemplates, setBasicContractTemplates] = React.useState<BasicContractTemplate[]>([])
const [selectedTemplateIds, setSelectedTemplateIds] = React.useState<number[]>([])
const [isLoadingTemplates, setIsLoadingTemplates] = React.useState(false)
+
+ // 비밀유지 계약서 첨부파일 관련 상태
+ const [ndaAttachments, setNdaAttachments] = React.useState<File[]>([])
+ const [isUploadingNdaFiles, setIsUploadingNdaFiles] = React.useState(false)
// 아이템 검색 필터링
React.useEffect(() => {
@@ -172,6 +177,8 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
setItemSearchQuery("")
setFilteredItems([])
setShowItemDropdown(false)
+ setNdaAttachments([])
+ setIsUploadingNdaFiles(false)
}
}, [props.open])
@@ -197,6 +204,30 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
setPqItems(prev => prev.filter(item => item.itemCode !== itemCode))
}
+ // 비밀유지 계약서 첨부파일 추가 함수
+ const handleAddNdaAttachment = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (files) {
+ const newFiles = Array.from(files)
+ setNdaAttachments(prev => [...prev, ...newFiles])
+ }
+ // input 초기화
+ event.target.value = ''
+ }
+
+ // 비밀유지 계약서 첨부파일 제거 함수
+ const handleRemoveNdaAttachment = (fileIndex: number) => {
+ setNdaAttachments(prev => prev.filter((_, index) => index !== fileIndex))
+ }
+
+ // 비밀유지 계약서가 선택되었는지 확인하는 함수
+ const isNdaTemplateSelected = () => {
+ return basicContractTemplates.some(template =>
+ selectedTemplateIds.includes(template.id) &&
+ template.templateName?.includes("비밀유지")
+ )
+ }
+
const onApprove = () => {
if (!type) return toast.error("PQ 유형을 선택하세요.")
if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.")
@@ -235,6 +266,23 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
console.log("📋 기본계약서 백그라운드 처리 시작", templates.length, "개 템플릿")
await processBasicContractsInBackground(templates, vendors)
}
+
+ // 3단계: 비밀유지 계약서 첨부파일이 있는 경우 저장
+ if (isNdaTemplateSelected() && ndaAttachments.length > 0) {
+ console.log("📎 비밀유지 계약서 첨부파일 처리 시작", ndaAttachments.length, "개 파일")
+
+ const ndaResult = await saveNdaAttachments({
+ vendorIds: vendors.map((v) => v.id),
+ files: ndaAttachments,
+ userId: session.user.id.toString()
+ })
+
+ if (ndaResult.success) {
+ toast.success(`비밀유지 계약서 첨부파일이 모두 저장되었습니다 (${ndaResult.summary?.success}/${ndaResult.summary?.total})`)
+ } else {
+ toast.error(`첨부파일 처리 중 일부 오류가 발생했습니다: ${ndaResult.error}`)
+ }
+ }
// 완료 후 다이얼로그 닫기
props.onOpenChange?.(false)
@@ -262,12 +310,14 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
for (let vendorIndex = 0; vendorIndex < vendors.length; vendorIndex++) {
const vendor = vendors[vendorIndex]
- // 벤더별 템플릿 데이터 생성
+ // 벤더별 템플릿 데이터 생성 (한글 변수명 사용)
const templateData = {
- vendor_name: vendor.vendorName || '협력업체명',
- address: vendor.address || '주소',
+ company_name: vendor.vendorName || '협력업체명',
+ company_address: vendor.address || '주소',
representative_name: vendor.representativeName || '대표자명',
- today_date: new Date().toLocaleDateString('ko-KR'),
+ signature_date: new Date().toLocaleDateString('ko-KR'),
+ tax_id: vendor.taxId || '사업자번호',
+ phone_number: vendor.phone || '전화번호',
}
console.log(`🔄 벤더 ${vendorIndex + 1}/${vendors.length} 템플릿 데이터:`, templateData)
@@ -559,6 +609,76 @@ export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...pro
)}
</div>
+ {/* 비밀유지 계약서 첨부파일 */}
+ {isNdaTemplateSelected() && (
+ <div className="space-y-2">
+ <Label>비밀유지 계약서 첨부파일</Label>
+
+ {/* 선택된 파일들 표시 */}
+ {ndaAttachments.length > 0 && (
+ <div className="space-y-2">
+ <div className="text-sm text-muted-foreground">
+ 선택된 파일 ({ndaAttachments.length}개)
+ </div>
+ <div className="space-y-1 max-h-32 overflow-y-auto border rounded-md p-2">
+ {ndaAttachments.map((file, index) => (
+ <div key={index} className="flex items-center justify-between text-sm bg-muted/50 rounded px-2 py-1">
+ <div className="flex-1 truncate">
+ <span className="font-medium">{file.name}</span>
+ <span className="text-muted-foreground ml-2">
+ ({(file.size / 1024 / 1024).toFixed(2)} MB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ className="h-6 w-6 p-0 hover:bg-destructive hover:text-destructive-foreground"
+ onClick={() => handleRemoveNdaAttachment(index)}
+ >
+ <X className="h-3 w-3" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ {/* 파일 선택 버튼 */}
+ <div className="flex items-center gap-2">
+ <input
+ type="file"
+ multiple
+ accept=".pdf,.doc,.docx,.xlsx,.xls,.png,.jpg,.jpeg"
+ onChange={handleAddNdaAttachment}
+ className="hidden"
+ id="nda-file-input"
+ />
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="gap-2"
+ onClick={() => document.getElementById('nda-file-input')?.click()}
+ disabled={isUploadingNdaFiles}
+ >
+ <Plus className="h-4 w-4" />
+ 파일 추가
+ </Button>
+ {isUploadingNdaFiles && (
+ <div className="text-sm text-muted-foreground">
+ 파일 업로드 중...
+ </div>
+ )}
+ </div>
+
+ <div className="text-xs text-muted-foreground">
+ 비밀유지 계약서와 관련된 첨부파일을 업로드하세요.
+ 각 벤더별로 동일한 파일이 저장됩니다.
+ </div>
+ </div>
+ )}
+
{/* <div className="space-y-2">
<Label>계약 항목 선택</Label>
{AGREEMENT_LIST.map((label) => (