From f2fafe555b65f9207c2c6e216b7d7b2ff83af866 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 3 Nov 2025 10:15:45 +0000 Subject: (최겸) 구매 PQ/실사 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/pq/pq-review-table-new/site-visit-dialog.tsx | 795 ++++++++++++++------- .../vendors-table-toolbar-actions.tsx | 99 ++- lib/pq/pq-review-table-new/vendors-table.tsx | 32 +- lib/pq/service.ts | 5 +- 4 files changed, 644 insertions(+), 287 deletions(-) (limited to 'lib/pq') diff --git a/lib/pq/pq-review-table-new/site-visit-dialog.tsx b/lib/pq/pq-review-table-new/site-visit-dialog.tsx index b1474150..a7cc3313 100644 --- a/lib/pq/pq-review-table-new/site-visit-dialog.tsx +++ b/lib/pq/pq-review-table-new/site-visit-dialog.tsx @@ -1,8 +1,8 @@ "use client" import * as React from "react" -import { CalendarIcon, X } from "lucide-react" -import { useForm } from "react-hook-form" +import { CalendarIcon, X, Plus, Trash2, Check, Search } from "lucide-react" +import { useForm, useFieldArray } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { format } from "date-fns" import { z } from "zod" @@ -37,8 +37,17 @@ import { import { Checkbox } from "@/components/ui/checkbox" import { Badge } from "@/components/ui/badge" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" import { toast } from "sonner" -import { getSiteVisitRequestAction } from "@/lib/site-visit/service" +import { getSiteVisitRequestAction, getUsersForSiteVisitAction } from "@/lib/site-visit/service" +import { cn } from "@/lib/utils" import { Dropzone, DropzoneDescription, @@ -51,7 +60,7 @@ import { // 방문실사 요청 폼 스키마 const siteVisitRequestSchema = z.object({ // 실사 기간 - inspectionDuration: z.number().min(0.5, "실사 기간을 입력해주세요."), + inspectionDuration: z.number().int().positive("실사 기간은 1일 이상이어야 합니다."), // 실사 요청일 requestedStartDate: z.date({ @@ -60,44 +69,66 @@ const siteVisitRequestSchema = z.object({ requestedEndDate: z.date({ required_error: "실사 종료일을 선택해주세요.", }), + // SHI 실사참석 예정부문 shiAttendees: z.object({ technicalSales: z.object({ checked: z.boolean().default(false), - count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0), - details: z.string().optional(), - }).default({ checked: false, count: 0, details: "" }), + attendees: z.array(z.object({ + name: z.string().min(1, "이름을 입력해주세요."), + department: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), + })).default([]), + }).default({ checked: false, attendees: [] }), design: z.object({ checked: z.boolean().default(false), - count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0), - details: z.string().optional(), - }).default({ checked: false, count: 0, details: "" }), + attendees: z.array(z.object({ + name: z.string().min(1, "이름을 입력해주세요."), + department: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), + })).default([]), + }).default({ checked: false, attendees: [] }), procurement: z.object({ checked: z.boolean().default(false), - count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0), - details: z.string().optional(), - }).default({ checked: false, count: 0, details: "" }), + attendees: z.array(z.object({ + name: z.string().min(1, "이름을 입력해주세요."), + department: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), + })).default([]), + }).default({ checked: false, attendees: [] }), quality: z.object({ checked: z.boolean().default(false), - count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0), - details: z.string().optional(), - }).default({ checked: false, count: 0, details: "" }), + attendees: z.array(z.object({ + name: z.string().min(1, "이름을 입력해주세요."), + department: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), + })).default([]), + }).default({ checked: false, attendees: [] }), production: z.object({ checked: z.boolean().default(false), - count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0), - details: z.string().optional(), - }).default({ checked: false, count: 0, details: "" }), + attendees: z.array(z.object({ + name: z.string().min(1, "이름을 입력해주세요."), + department: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), + })).default([]), + }).default({ checked: false, attendees: [] }), commissioning: z.object({ checked: z.boolean().default(false), - count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0), - details: z.string().optional(), - }).default({ checked: false, count: 0, details: "" }), + attendees: z.array(z.object({ + name: z.string().min(1, "이름을 입력해주세요."), + department: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), + })).default([]), + }).default({ checked: false, attendees: [] }), other: z.object({ checked: z.boolean().default(false), - count: z.number().min(0, "참석 인원은 0명 이상이어야 합니다.").default(0), - details: z.string().optional(), - }).default({ checked: false, count: 0, details: "" }), + attendees: z.array(z.object({ + name: z.string().min(1, "이름을 입력해주세요."), + department: z.string().optional(), + email: z.string().email("유효한 이메일을 입력해주세요.").or(z.string().length(0)), + })).default([]), + }).default({ checked: false, attendees: [] }), }), // SHI 참석자 정보 (JSON 형태로 저장) - 기존 필드 유지 @@ -122,9 +153,287 @@ const siteVisitRequestSchema = z.object({ // 추가 요청사항 additionalRequests: z.string().optional(), +}).refine((data) => { + // 종료일이 시작일보다 이후여야 함 + if (data.requestedStartDate && data.requestedEndDate) { + return data.requestedEndDate >= data.requestedStartDate; + } + return true; +}, { + message: "종료일은 시작일보다 이후여야 합니다.", + path: ["requestedEndDate"], +}).refine((data) => { + // SHI 참석자 정보 검증: 부서 상관없이 전체 참석자가 최소 1명 이상이어야 함 + const totalAttendees = Object.values(data.shiAttendees).reduce((total, attendee) => { + if (attendee.checked && attendee.attendees.length > 0) { + return total + attendee.attendees.length; + } + return total; + }, 0); + return totalAttendees >= 1; +}, { + message: "참석자는 부서 상관없이 최소 1명 이상 필수입니다.", + path: ["shiAttendees"], }) -type SiteVisitRequestFormValues = z.infer +export type SiteVisitRequestFormValues = z.infer + +// 사용자 타입 정의 +interface SiteVisitUser { + id: number; + name: string; + email: string; + deptName: string | null; +} + +// 참석자 섹션 컴포넌트 +function AttendeeSection({ + form, + itemKey, + label, + isPending, +}: { + form: ReturnType> + itemKey: keyof SiteVisitRequestFormValues['shiAttendees'] + label: string + isPending: boolean +}) { + const { fields, append, remove } = useFieldArray({ + control: form.control, + name: `shiAttendees.${itemKey}.attendees` as any, + }); + + const isChecked = form.watch(`shiAttendees.${itemKey}.checked`); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(""); + const [users, setUsers] = React.useState([]); + const [isLoadingUsers, setIsLoadingUsers] = React.useState(false); + + const loadUsers = React.useCallback(async () => { + setIsLoadingUsers(true); + try { + const result = await getUsersForSiteVisitAction( + searchQuery.trim() || undefined + ); + if (result.success && result.data) { + setUsers(result.data); + } + } catch (error) { + console.error("사용자 목록 로드 오류:", error); + toast.error("사용자 목록을 불러오는데 실패했습니다."); + } finally { + setIsLoadingUsers(false); + } + }, [searchQuery]); + + // 사용자 목록 가져오기 + React.useEffect(() => { + if (isPopoverOpen && isChecked) { + loadUsers(); + } + }, [isPopoverOpen, isChecked, loadUsers]); + + // 검색 쿼리 변경 시 사용자 목록 다시 로드 (debounce) + React.useEffect(() => { + if (!isPopoverOpen || !isChecked) return; + + const timer = setTimeout(() => { + loadUsers(); + }, 300); + + return () => clearTimeout(timer); + }, [searchQuery, isPopoverOpen, isChecked, loadUsers]); + + const handleUserSelect = (user: SiteVisitUser) => { + // 현재 폼의 attendees 값 가져오기 + const currentAttendees = form.getValues(`shiAttendees.${itemKey}.attendees`) as Array<{ + name: string; + department?: string; + email: string; + }>; + + // 이미 선택된 사용자인지 확인 + const existingIndex = currentAttendees.findIndex( + (attendee) => attendee.email === user.email + ); + + if (existingIndex >= 0) { + // 이미 선택된 경우 제거 + remove(existingIndex); + } else { + // 새로 추가 + append({ + name: user.name, + department: user.deptName || "", + email: user.email, + }); + } + }; + + const isUserSelected = (userEmail: string) => { + const currentAttendees = form.getValues(`shiAttendees.${itemKey}.attendees`) as Array<{ + name: string; + department?: string; + email: string; + }>; + return currentAttendees.some((attendee) => attendee.email === userEmail); + }; + + return ( +
+
+
+ ( + + + { + field.onChange(checked); + // 체크 해제 시 참석자 목록 초기화 + if (!checked) { + form.setValue(`shiAttendees.${itemKey}.attendees` as any, []); + setIsPopoverOpen(false); + } + }} + disabled={isPending} + /> + + {label} + + )} + /> +
+ {isChecked && ( +
+ + 참석인원: {fields.length}명 + +
+ )} +
+ + {isChecked && ( +
+ {/* 사용자 선택 UI */} + + + + + + + + + + {isLoadingUsers ? "로딩 중..." : "검색 결과가 없습니다."} + + + {users.map((user) => { + const selected = isUserSelected(user.email); + return ( + handleUserSelect(user)} + className="cursor-pointer" + > + +
+
+ + {user.name} + + {user.deptName && ( + + ({user.deptName}) + + )} +
+ + {user.email} + +
+
+ ); + })} +
+
+
+
+
+ + {/* 선택된 사용자 목록 */} + {fields.length > 0 && ( +
+ {fields.map((fieldItem, index) => { + // 폼에서 실제 값을 가져오기 + const attendeesArray = form.watch(`shiAttendees.${itemKey}.attendees` as any) as Array<{ + name: string; + department?: string; + email: string; + }>; + const attendee = attendeesArray[index]; + + if (!attendee) return null; + + return ( +
+
+
+ {attendee.name} + {attendee.department && ( + + ({attendee.department}) + + )} +
+
+ {attendee.email} +
+
+ +
+ ); + })} +
+ )} +
+ )} +
+ ); +} interface SiteVisitDialogProps { isOpen: boolean @@ -134,6 +443,7 @@ interface SiteVisitDialogProps { id: number investigationMethod?: "PURCHASE_SELF_EVAL" | "DOCUMENT_EVAL" | "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL" investigationAddress?: string + investigationNotes?: string vendorName: string vendorCode: string projectName?: string @@ -156,17 +466,17 @@ export function SiteVisitDialog({ const form = useForm({ resolver: zodResolver(siteVisitRequestSchema), defaultValues: { - inspectionDuration: 1.0, + inspectionDuration: 1, requestedStartDate: undefined, requestedEndDate: undefined, shiAttendees: { - technicalSales: { checked: false, count: 0, details: "" }, - design: { checked: false, count: 0, details: "" }, - procurement: { checked: false, count: 0, details: "" }, - quality: { checked: false, count: 0, details: "" }, - production: { checked: false, count: 0, details: "" }, - commissioning: { checked: false, count: 0, details: "" }, - other: { checked: false, count: 0, details: "" }, + technicalSales: { checked: false, attendees: [] }, + design: { checked: false, attendees: [] }, + procurement: { checked: false, attendees: [] }, + quality: { checked: false, attendees: [] }, + production: { checked: false, attendees: [] }, + commissioning: { checked: false, attendees: [] }, + other: { checked: false, attendees: [] }, }, shiAttendeeDetails: "", vendorRequests: { @@ -198,20 +508,50 @@ export function SiteVisitDialog({ // 기존 데이터를 form에 로드 const data = existingRequest.data form.reset({ - inspectionDuration: data.inspectionDuration || 1.0, + inspectionDuration: typeof data.inspectionDuration === 'number' ? data.inspectionDuration : (parseFloat(String(data.inspectionDuration || '1')) || 1), requestedStartDate: data.requestedStartDate ? new Date(data.requestedStartDate) : undefined, requestedEndDate: data.requestedEndDate ? new Date(data.requestedEndDate) : undefined, - shiAttendees: data.shiAttendees || { - technicalSales: { checked: false, count: 0, details: "" }, - design: { checked: false, count: 0, details: "" }, - procurement: { checked: false, count: 0, details: "" }, - quality: { checked: false, count: 0, details: "" }, - production: { checked: false, count: 0, details: "" }, - commissioning: { checked: false, count: 0, details: "" }, - other: { checked: false, count: 0, details: "" }, - }, - shiAttendeeDetails: data.shiAttendeeDetails || "", - vendorRequests: data.vendorRequests || { + shiAttendees: (() => { + // 기존 데이터 형식 변환 (호환성 유지) + if (data.shiAttendees) { + const converted: any = {}; + Object.keys(data.shiAttendees).forEach((key) => { + const oldData = (data.shiAttendees as any)[key]; + if (oldData && typeof oldData === 'object') { + // 기존 형식 {checked, count, details} → 새 형식 {checked, attendees} + if (oldData.attendees && Array.isArray(oldData.attendees)) { + converted[key] = oldData; // 이미 새 형식 + } else { + // 기존 형식 변환 + converted[key] = { + checked: oldData.checked || false, + attendees: oldData.count > 0 && oldData.details + ? [{ + name: oldData.details.split('/')[0]?.trim() || '', + department: oldData.details.split('/')[1]?.trim() || '', + email: '' + }] + : [] + }; + } + } else { + converted[key] = { checked: false, attendees: [] }; + } + }); + return converted; + } + return { + technicalSales: { checked: false, attendees: [] }, + design: { checked: false, attendees: [] }, + procurement: { checked: false, attendees: [] }, + quality: { checked: false, attendees: [] }, + production: { checked: false, attendees: [] }, + commissioning: { checked: false, attendees: [] }, + other: { checked: false, attendees: [] }, + }; + })(), + shiAttendeeDetails: (data as any).shiAttendeeDetails || "", + vendorRequests: (data.vendorRequests && typeof data.vendorRequests === 'object') ? data.vendorRequests : { availableDates: false, factoryName: false, factoryLocation: false, @@ -223,7 +563,7 @@ export function SiteVisitDialog({ accessProcedure: false, other: false, }, - otherVendorRequests: data.otherVendorRequests || "", + otherVendorRequests: (data as any).otherVendorRequests || "", additionalRequests: data.additionalRequests || "", }) return @@ -231,17 +571,17 @@ export function SiteVisitDialog({ // 기본값으로 폼 초기화 (기존 요청이 없는 경우) form.reset({ - inspectionDuration: 1.0, + inspectionDuration: 1, requestedStartDate: undefined, requestedEndDate: undefined, shiAttendees: { - technicalSales: { checked: false, count: 0, details: "" }, - design: { checked: false, count: 0, details: "" }, - procurement: { checked: false, count: 0, details: "" }, - quality: { checked: false, count: 0, details: "" }, - production: { checked: false, count: 0, details: "" }, - commissioning: { checked: false, count: 0, details: "" }, - other: { checked: false, count: 0, details: "" }, + technicalSales: { checked: false, attendees: [] }, + design: { checked: false, attendees: [] }, + procurement: { checked: false, attendees: [] }, + quality: { checked: false, attendees: [] }, + production: { checked: false, attendees: [] }, + commissioning: { checked: false, attendees: [] }, + other: { checked: false, attendees: [] }, }, shiAttendeeDetails: "", vendorRequests: { @@ -318,7 +658,9 @@ export function SiteVisitDialog({ !open && onClose()}> - {isReinspection ? "재실사 요청 생성" : "방문실사 요청 생성"} + {isReinspection ? "재실사 요청 생성" : "방문실사 요청 생성"} + {getInvestigationMethodLabel(investigation.investigationMethod || "")} + {isReinspection ? "협력업체에 재실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다." @@ -329,6 +671,16 @@ export function SiteVisitDialog({
+ {/* QM 의견 (있는 경우에만 표시) */} + +
+ QM 의견 +
+

{investigation.investigationNotes}

+
+
+ + {/* 대상업체 정보 */}
@@ -362,31 +714,46 @@ export function SiteVisitDialog({ {/* 실사방법 */} -
+ {/*
실사방법
{getInvestigationMethodLabel(investigation.investigationMethod || "")}
-
- +
*/} +
{/* 실사기간 */} ( - + 실사기간 (W/D 기준)
field.onChange(parseFloat(e.target.value) || 0)} + value={field.value || ''} + onChange={(e) => { + const value = parseInt(e.target.value, 10); + if (Number.isNaN(value) || value < 1) { + field.onChange(1); + } else { + field.onChange(value); + // 실사 기간이 변경되면 종료일 자동 계산 + const startDate = form.getValues('requestedStartDate'); + if (startDate) { + const endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + value - 1); + form.setValue('requestedEndDate', endDate); + } + } + }} disabled={isPending} className="w-24" /> @@ -399,7 +766,7 @@ export function SiteVisitDialog({ /> {/* 실사요청일 */} -
+ { + field.onChange(date); + // 시작일이 변경되면 종료일 자동 계산 + if (date) { + const duration = form.getValues('inspectionDuration') || 1; + const endDate = new Date(date); + endDate.setDate(endDate.getDate() + duration - 1); + form.setValue('requestedEndDate', endDate); + // 실사 기간도 재계산 + const currentEndDate = form.getValues('requestedEndDate'); + if (currentEndDate) { + const diffTime = currentEndDate.getTime() - date.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + if (diffDays > 0) { + form.setValue('inspectionDuration', diffDays); + } + } + } + }} disabled={(date) => date < new Date()} initialFocus /> @@ -441,139 +826,112 @@ export function SiteVisitDialog({ ( - - 실사 종료일 - - - - - - - - date < new Date()} - initialFocus - /> - - - - - )} + render={({ field }) => { + const startDate = form.watch('requestedStartDate'); + return ( + + 실사 종료일 + + + + + + + + { + field.onChange(date); + // 종료일이 변경되면 실사 기간 자동 계산 + if (date && startDate) { + const diffTime = date.getTime() - startDate.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1; + if (diffDays > 0) { + form.setValue('inspectionDuration', diffDays); + } + } + }} + disabled={(date) => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + if (date < today) return true; + if (startDate && date < startDate) return true; + return false; + }} + initialFocus + /> + + + + + ); + }} />
{/* SHI 실사참석 예정부문 */}
- SHI 실사참석 예정부문 ※ 필수값 + SHI 실사 참석 인원 정보 (*)
삼성중공업에 어떤 부문의 담당자가 몇 명 실사 참석 예정인지에 대한 정보를 입력하세요. +
부서 상관없이 최소 1명 이상 필수입니다.
-
- - - - 참석여부 - 부문 - 참석인원 - 참석자 정보 - - - - {[ - { key: "technicalSales", label: "기술영업" }, - { key: "design", label: "설계" }, - { key: "procurement", label: "구매" }, - { key: "quality", label: "품질" }, - { key: "production", label: "생산" }, - { key: "commissioning", label: "시운전" }, - { key: "other", label: "기타" }, - ].map((item) => ( - - - ( - - - - - - )} - /> - - - {item.label} - - - ( - -
- - field.onChange(parseInt(e.target.value) || 0)} - disabled={isPending} - className="w-16 h-8" - /> - - -
- -
- )} - /> -
- - ( - - - - - - - )} - /> - -
- ))} -
-
+
+ + + + + + +
{/* 전체 참석자 상세정보 */} @@ -597,63 +955,6 @@ export function SiteVisitDialog({ />
- {/* 협력업체 요청정보 및 자료 */} - {/*
- 협력업체 요청정보 및 자료 -
- 협력업체에게 요청할 정보를 선택하세요. -
-
- {[ - { key: "factoryName", label: "공장명" }, - { key: "factoryLocation", label: "공장위치" }, - { key: "factoryAddress", label: "공장주소" }, - { key: "factoryPicName", label: "공장 PIC 이름" }, - { key: "factoryPicPhone", label: "공장 PIC 전화번호" }, - { key: "factoryPicEmail", label: "공장 PIC 이메일" }, - { key: "factoryDirections", label: "공장 가는 방법" }, - { key: "accessProcedure", label: "공장 출입절차" }, - { key: "other", label: "기타" }, - ].map((item) => ( - ( - - - - - {item.label} - - )} - /> - ))} -
- {/* ( - - 기타 요청사항 - -