From ff902243a658067fae858a615c0629aa2e0a4837 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 11 Jun 2025 12:18:38 +0000 Subject: (대표님) 20250611 21시 15분 OCR 등 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/admin-users/table/add-ausers-dialog.tsx | 2 +- lib/b-rfq/repository.ts | 0 lib/b-rfq/service.ts | 291 ++++++++++ lib/b-rfq/summary-table/add-new-rfq-dialog.tsx | 523 +++++++++++++++++ lib/b-rfq/summary-table/summary-rfq-columns.tsx | 499 +++++++++++++++++ .../summary-table/summary-rfq-filter-sheet.tsx | 617 +++++++++++++++++++++ .../summary-rfq-table-toolbar-actions.tsx | 68 +++ lib/b-rfq/summary-table/summary-rfq-table.tsx | 285 ++++++++++ lib/b-rfq/validations.ts | 100 ++++ lib/users/send-otp.ts | 68 +-- lib/vendor-document-list/dolce-upload-service.ts | 112 ++-- lib/vendor-document-list/import-service.ts | 288 ++++++++-- .../table/enhanced-doc-table-columns.tsx | 222 ++++---- .../table/enhanced-documents-table.tsx | 327 ++++++----- .../table/revision-upload-dialog.tsx | 132 ++++- .../table/stage-revision-expanded-content.tsx | 16 + lib/welding/repository.ts | 59 +- lib/welding/service.ts | 225 +++++--- lib/welding/table/ocr-table-columns.tsx | 115 ++-- lib/welding/table/ocr-table-toolbar-actions.tsx | 96 +++- lib/welding/table/ocr-table.tsx | 16 +- lib/welding/table/update-ocr-row-sheet.tsx | 187 +++++++ lib/welding/validation.ts | 10 + 23 files changed, 3732 insertions(+), 526 deletions(-) create mode 100644 lib/b-rfq/repository.ts create mode 100644 lib/b-rfq/service.ts create mode 100644 lib/b-rfq/summary-table/add-new-rfq-dialog.tsx create mode 100644 lib/b-rfq/summary-table/summary-rfq-columns.tsx create mode 100644 lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx create mode 100644 lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx create mode 100644 lib/b-rfq/summary-table/summary-rfq-table.tsx create mode 100644 lib/b-rfq/validations.ts create mode 100644 lib/welding/table/update-ocr-row-sheet.tsx (limited to 'lib') diff --git a/lib/admin-users/table/add-ausers-dialog.tsx b/lib/admin-users/table/add-ausers-dialog.tsx index dd29c190..64941965 100644 --- a/lib/admin-users/table/add-ausers-dialog.tsx +++ b/lib/admin-users/table/add-ausers-dialog.tsx @@ -82,7 +82,7 @@ export function AddUserDialog() { companyId: null, language:'en', // roles는 array, 여기서는 단일 선택 시 [role]로 담음 - roles: [], + roles: ["Vendor Admin"], domain:'partners' // domain, etc. 필요하다면 추가 }, diff --git a/lib/b-rfq/repository.ts b/lib/b-rfq/repository.ts new file mode 100644 index 00000000..e69de29b diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts new file mode 100644 index 00000000..f64eb46c --- /dev/null +++ b/lib/b-rfq/service.ts @@ -0,0 +1,291 @@ +'use server' + +import { revalidateTag, unstable_cache } from "next/cache" +import { count, desc, asc, and, or, gte, lte, ilike, eq } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns" +import db from "@/db/db" +import { RfqDashboardView, bRfqs, projects, users } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 +import { rfqDashboardView } from "@/db/schema" // 뷰 import +import type { SQL } from "drizzle-orm" +import { CreateRfqInput, GetRFQDashboardSchema, createRfqServerSchema } from "./validations" + +export async function getRFQDashboard(input: GetRFQDashboardSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + const rfqFilterMapping = createRFQFilterMapping(); + const joinedTables = getRFQJoinedTables(); + + // 1) 고급 필터 조건 + let advancedWhere: SQL | undefined = undefined; + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: rfqDashboardView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + joinedTables, + customColumnMapping: rfqFilterMapping, + }); + } + + // 2) 기본 필터 조건 + let basicWhere: SQL | undefined = undefined; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: rfqDashboardView, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || 'and', + joinedTables, + customColumnMapping: rfqFilterMapping, + }); + } + + // 3) 글로벌 검색 조건 + let globalWhere: SQL | undefined = undefined; + if (input.search) { + const s = `%${input.search}%`; + + const validSearchConditions: SQL[] = []; + + const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s); + if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); + + const descriptionCondition = ilike(rfqDashboardView.description, s); + if (descriptionCondition) validSearchConditions.push(descriptionCondition); + + const projectNameCondition = ilike(rfqDashboardView.projectName, s); + if (projectNameCondition) validSearchConditions.push(projectNameCondition); + + const projectCodeCondition = ilike(rfqDashboardView.projectCode, s); + if (projectCodeCondition) validSearchConditions.push(projectCodeCondition); + + const picNameCondition = ilike(rfqDashboardView.picName, s); + if (picNameCondition) validSearchConditions.push(picNameCondition); + + const packageNoCondition = ilike(rfqDashboardView.packageNo, s); + if (packageNoCondition) validSearchConditions.push(packageNoCondition); + + const packageNameCondition = ilike(rfqDashboardView.packageName, s); + if (packageNameCondition) validSearchConditions.push(packageNameCondition); + + if (validSearchConditions.length > 0) { + globalWhere = or(...validSearchConditions); + } + } + + + + // 6) 최종 WHERE 조건 생성 + const whereConditions: SQL[] = []; + + if (advancedWhere) whereConditions.push(advancedWhere); + if (basicWhere) whereConditions.push(basicWhere); + if (globalWhere) whereConditions.push(globalWhere); + + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // 7) 전체 데이터 수 조회 + const totalResult = await db + .select({ count: count() }) + .from(rfqDashboardView) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + if (total === 0) { + return { data: [], pageCount: 0, total: 0 }; + } + + console.log(total) + + // 8) 정렬 및 페이징 처리된 데이터 조회 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof rfqDashboardView.$inferSelect; + return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]); + }); + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(rfqDashboardView.createdAt)); + } + + const rfqData = await db + .select() + .from(rfqDashboardView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset); + + const pageCount = Math.ceil(total / input.perPage); + + return { data: rfqData, pageCount, total }; + } catch (err) { + console.error("Error in getRFQDashboard:", err); + return { data: [], pageCount: 0, total: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["rfq-dashboard"], + } + )(); +} + +// 헬퍼 함수들 +function createRFQFilterMapping() { + return { + // 뷰의 컬럼명과 실제 필터링할 컬럼 매핑 + rfqCode: rfqDashboardView.rfqCode, + description: rfqDashboardView.description, + status: rfqDashboardView.status, + projectName: rfqDashboardView.projectName, + projectCode: rfqDashboardView.projectCode, + picName: rfqDashboardView.picName, + packageNo: rfqDashboardView.packageNo, + packageName: rfqDashboardView.packageName, + dueDate: rfqDashboardView.dueDate, + overallProgress: rfqDashboardView.overallProgress, + createdAt: rfqDashboardView.createdAt, + }; +} + +function getRFQJoinedTables() { + return { + // 조인된 테이블 정보 (뷰이므로 실제로는 사용되지 않을 수 있음) + projects, + users, + }; +} + +// ================================================================ +// 3. RFQ Dashboard 타입 정의 +// ================================================================ + +async function generateNextSerial(picCode: string): Promise { + try { + // 해당 picCode로 시작하는 RFQ 개수 조회 + const existingCount = await db + .select({ count: count() }) + .from(bRfqs) + .where(eq(bRfqs.picCode, picCode)) + + const nextSerial = (existingCount[0]?.count || 0) + 1 + return nextSerial.toString().padStart(5, '0') // 5자리로 패딩 + } catch (error) { + console.error("시리얼 번호 생성 오류:", error) + return "00001" // 기본값 + } + } + export async function createRfqAction(input: CreateRfqInput) { + try { + // 입력 데이터 검증 + const validatedData = createRfqServerSchema.parse(input) + + // RFQ 코드 자동 생성: N + picCode + 시리얼5자리 + const serialNumber = await generateNextSerial(validatedData.picCode) + const rfqCode = `N${validatedData.picCode}${serialNumber}` + + // 데이터베이스에 삽입 + const result = await db.insert(bRfqs).values({ + rfqCode, + projectId: validatedData.projectId, + dueDate: validatedData.dueDate, + status: "DRAFT", + picCode: validatedData.picCode, + picName: validatedData.picName || null, + EngPicName: validatedData.engPicName || null, + packageNo: validatedData.packageNo || null, + packageName: validatedData.packageName || null, + remark: validatedData.remark || null, + projectCompany: validatedData.projectCompany || null, + projectFlag: validatedData.projectFlag || null, + projectSite: validatedData.projectSite || null, + createdBy: validatedData.createdBy, + updatedBy: validatedData.updatedBy, + }).returning({ + id: bRfqs.id, + rfqCode: bRfqs.rfqCode, + }) + + // 관련 페이지 캐시 무효화 + revalidateTag("rfq-dashboard") + + + return { + success: true, + data: result[0], + message: "RFQ가 성공적으로 생성되었습니다", + } + + } catch (error) { + console.error("RFQ 생성 오류:", error) + + + return { + success: false, + error: "RFQ 생성에 실패했습니다", + } + } + } + + // RFQ 코드 중복 확인 액션 + export async function checkRfqCodeExists(rfqCode: string) { + try { + const existing = await db.select({ id: bRfqs.id }) + .from(bRfqs) + .where(eq(bRfqs.rfqCode, rfqCode)) + .limit(1) + + return existing.length > 0 + } catch (error) { + console.error("RFQ 코드 확인 오류:", error) + return false + } + } + + // picCode별 다음 예상 RFQ 코드 미리보기 + export async function previewNextRfqCode(picCode: string) { + try { + const serialNumber = await generateNextSerial(picCode) + return `N${picCode}${serialNumber}` + } catch (error) { + console.error("RFQ 코드 미리보기 오류:", error) + return `N${picCode}00001` + } + } + +const getBRfqById = async (id: number): Promise => { + // 1) RFQ 단건 조회 + const rfqsRes = await db + .select() + .from(rfqDashboardView) + .where(eq(rfqDashboardView.rfqId, id)) + .limit(1); + + if (rfqsRes.length === 0) return null; + const rfqRow = rfqsRes[0]; + + // 3) RfqWithItems 형태로 반환 + const result: RfqDashboardView = { + ...rfqRow, + + }; + + return result; + }; + + + export const findBRfqById = async (id: number): Promise => { + try { + + const rfq = await getBRfqById(id); + + return rfq; + } catch (error) { + throw new Error('Failed to fetch user'); + } + }; + \ No newline at end of file diff --git a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx new file mode 100644 index 00000000..2333d9cf --- /dev/null +++ b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx @@ -0,0 +1,523 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { format } from "date-fns" +import { CalendarIcon, Plus, Loader2, Eye } from "lucide-react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Calendar } from "@/components/ui/calendar" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { ProjectSelector } from "@/components/ProjectSelector" +import { createRfqAction, previewNextRfqCode } from "../service" + +export type Project = { + id: number; + projectCode: string; + projectName: string; +} + +// 클라이언트 폼 스키마 (projectId 필수로 변경) +const createRfqSchema = z.object({ + projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경 + dueDate: z.date({ + required_error: "마감일을 선택해주세요", + }), + picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"), + picName: z.string().optional(), + engPicName: z.string().optional(), + packageNo: z.string().min(1, "패키지 번호를 입력해주세요"), + packageName: z.string().min(1, "패키지명을 입력해주세요"), + remark: z.string().optional(), + projectCompany: z.string().optional(), + projectFlag: z.string().optional(), + projectSite: z.string().optional(), +}) + +type CreateRfqFormValues = z.infer + +interface CreateRfqDialogProps { + onSuccess?: () => void; +} + +export function CreateRfqDialog({ onSuccess }: CreateRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [previewCode, setPreviewCode] = React.useState("") + const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) + const router = useRouter() + const { data: session } = useSession() + + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null; + }, [session]); + + const form = useForm({ + resolver: zodResolver(createRfqSchema), + defaultValues: { + projectId: undefined, + dueDate: undefined, + picCode: "", + picName: "", + engPicName: "", + packageNo: "", + packageName: "", + remark: "", + projectCompany: "", + projectFlag: "", + projectSite: "", + }, + }) + + // picCode 변경 시 미리보기 업데이트 + const watchedPicCode = form.watch("picCode") + + React.useEffect(() => { + if (watchedPicCode && watchedPicCode.length > 0) { + setIsLoadingPreview(true) + const timer = setTimeout(async () => { + try { + const preview = await previewNextRfqCode(watchedPicCode) + setPreviewCode(preview) + } catch (error) { + console.error("미리보기 오류:", error) + setPreviewCode("") + } finally { + setIsLoadingPreview(false) + } + }, 500) // 500ms 디바운스 + + return () => clearTimeout(timer) + } else { + setPreviewCode("") + } + }, [watchedPicCode]) + + // 다이얼로그 열림/닫힘 처리 및 폼 리셋 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + + // 다이얼로그가 닫힐 때 폼과 상태 초기화 + if (!newOpen) { + form.reset() + setPreviewCode("") + setIsLoadingPreview(false) + } + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + + const onSubmit = async (data: CreateRfqFormValues) => { + if (!userId) { + toast.error("로그인이 필요합니다") + return + } + + setIsLoading(true) + + try { + // 서버 액션 호출 - Date 객체를 직접 전달 + const result = await createRfqAction({ + projectId: data.projectId, // 이제 항상 값이 있음 + dueDate: data.dueDate, // Date 객체 직접 전달 + picCode: data.picCode, + picName: data.picName || "", + engPicName: data.engPicName || "", + packageNo: data.packageNo, + packageName: data.packageName, + remark: data.remark || "", + projectCompany: data.projectCompany || "", + projectFlag: data.projectFlag || "", + projectSite: data.projectSite || "", + createdBy: userId, + updatedBy: userId, + }) + + if (result.success) { + toast.success(result.message, { + description: `RFQ 코드: ${result.data?.rfqCode}`, + }) + + // 다이얼로그 닫기 (handleOpenChange에서 리셋 처리됨) + setOpen(false) + + // 성공 콜백 실행 + if (onSuccess) { + onSuccess() + } + + } else { + toast.error(result.error || "RFQ 생성에 실패했습니다") + } + + } catch (error) { + console.error('RFQ 생성 오류:', error) + toast.error("RFQ 생성에 실패했습니다", { + description: "알 수 없는 오류가 발생했습니다", + }) + } finally { + setIsLoading(false) + } + } + + const handleProjectSelect = (project: Project | null) => { + if (project === null) { + form.setValue("projectId", undefined as any); // 타입 에러 방지 + return; + } + form.setValue("projectId", project.id); + }; + + return ( + + + + + + {/* 고정된 헤더 */} + + 새 RFQ 생성 + + 새로운 RFQ를 생성합니다. 필수 정보를 입력해주세요. + + + + {/* 스크롤 가능한 컨텐츠 영역 */} +
+
+ + + {/* 프로젝트 선택 (필수) */} + ( + + + 프로젝트 * + + + + + + + )} + /> + + {/* 마감일 (필수) */} + ( + + + 마감일 * + + + + + + + + + + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + + + + + )} + /> + + {/* 구매 담당자 코드 (필수) + 미리보기 */} + ( + + + 구매 담당자 코드 * + + +
+ + {/* RFQ 코드 미리보기 */} + {previewCode && ( +
+ + + 생성될 RFQ 코드: + + + {isLoadingPreview ? "생성 중..." : previewCode} + +
+ )} +
+
+ + RFQ 코드는 N + 담당자코드 + 시리얼번호(5자리) 형식으로 자동 생성됩니다 + + +
+ )} + /> + + {/* 담당자 정보 (두 개 나란히) */} +
+

담당자 정보

+
+ {/* 구매 담당자 */} + ( + + 구매 담당자명 + + + + + + )} + /> + + {/* 설계 담당자 */} + ( + + 설계 담당자명 + + + + + + )} + /> +
+
+ + {/* 패키지 정보 (두 개 나란히) - 필수 */} +
+

패키지 정보

+
+ {/* 패키지 번호 (필수) */} + ( + + + 패키지 번호 * + + + + + + + )} + /> + + {/* 패키지명 (필수) */} + ( + + + 패키지명 * + + + + + + + )} + /> +
+
+ + {/* 프로젝트 상세 정보 */} +
+

프로젝트 상세 정보

+
+ ( + + 프로젝트 회사 + + + + + + )} + /> + +
+ ( + + 프로젝트 플래그 + + + + + + )} + /> + + ( + + 프로젝트 사이트 + + + + + + )} + /> +
+
+
+ + {/* 비고 */} + ( + + 비고 + +