diff options
Diffstat (limited to 'lib')
| -rw-r--r-- | lib/risk-management/table/risks-mail-dialog.tsx | 10 | ||||
| -rw-r--r-- | lib/vendor-registration-status/vendor-registration-status-view.tsx | 300 | ||||
| -rw-r--r-- | lib/vendor-regular-registrations/safety-qualification-update-sheet.tsx | 143 | ||||
| -rw-r--r-- | lib/vendor-regular-registrations/service.ts | 62 | ||||
| -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.tsx | 22 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 111 | ||||
| -rw-r--r-- | lib/vendors/table/request-pq-dialog.tsx | 148 |
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) => (
|
