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 --- app/[lng]/partners/pq_new/page.tsx | 6 +- components/file-manager/FileManager.tsx | 25 +- config/vendorInvestigationsColumnsConfig.ts | 7 + db/schema/pq.ts | 1 + .../approval-template-table-toolbar-actions.tsx | 53 -- lib/mail/templates/site-visit-request.hbs | 24 +- lib/mail/templates/supplement-document-request.hbs | 207 ++++++ 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 +- lib/rfq-last/service.ts | 229 ++++++ lib/rfq-last/table/rfq-table-toolbar-actions.tsx | 59 +- lib/rfq-last/table/update-general-rfq-dialog.tsx | 749 +++++++++++++++++++ lib/site-visit/client-site-visit-wrapper.tsx | 21 +- lib/site-visit/service.ts | 239 +++++-- lib/site-visit/shi-attendees-dialog.tsx | 79 +- lib/site-visit/vendor-info-view-dialog.tsx | 582 +++++++++------ lib/vendor-investigation/service.ts | 198 ++++- .../table/change-qm-manager-dialog.tsx | 183 +++++ .../table/investigation-table-columns.tsx | 24 +- .../table/investigation-table-toolbar-actions.tsx | 50 +- .../table/investigation-table.tsx | 28 + 23 files changed, 3031 insertions(+), 664 deletions(-) create mode 100644 lib/mail/templates/supplement-document-request.hbs create mode 100644 lib/rfq-last/table/update-general-rfq-dialog.tsx create mode 100644 lib/vendor-investigation/table/change-qm-manager-dialog.tsx diff --git a/app/[lng]/partners/pq_new/page.tsx b/app/[lng]/partners/pq_new/page.tsx index 389a35a2..eea5b21d 100644 --- a/app/[lng]/partners/pq_new/page.tsx +++ b/app/[lng]/partners/pq_new/page.tsx @@ -202,6 +202,7 @@ export default async function PQListPage() { 유형 + PQ 번호 프로젝트 상태 요청일 @@ -213,7 +214,7 @@ export default async function PQListPage() { {pqList.length === 0 ? ( - + 요청된 PQ가 없습니다. @@ -235,6 +236,9 @@ export default async function PQListPage() { "일반"} + + {pq.pqNumber || "-"} + {pq.projectName || "-"} diff --git a/components/file-manager/FileManager.tsx b/components/file-manager/FileManager.tsx index c56bb16a..8463f03e 100644 --- a/components/file-manager/FileManager.tsx +++ b/components/file-manager/FileManager.tsx @@ -414,7 +414,7 @@ export function FileManager({ projectId }: FileManagerProps) { const { data: session } = useSession(); const [items, setItems] = useState([]); const [treeItems, setTreeItems] = useState([]); - const [currentPath, setCurrentPath] = useState([]); + const [currentPath, setCurrentPath] = useState<{ id: string; name: string }[]>([]); const [currentParentId, setCurrentParentId] = useState(null); const [selectedItems, setSelectedItems] = useState>(new Set()); const [expandedFolders, setExpandedFolders] = useState>(new Set()); @@ -543,6 +543,16 @@ export function FileManager({ projectId }: FileManagerProps) { } }); + const byName = (a: FileItem, b: FileItem) => + a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + // sort every children array + for (const node of itemMap.values()) { + if (node.children && node.children.length > 0) { + node.children.sort(byName); + } + } + // sort root nodes + rootItems.sort(byName); return rootItems; }; @@ -566,7 +576,9 @@ export function FileManager({ projectId }: FileManagerProps) { if (!response.ok) throw new Error('Failed to fetch files'); const data = await response.json(); - setItems(data); + const byName = (a: FileItem, b: FileItem) => + a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }); + setItems([...data].sort(byName)); // Build tree structure if (viewMode === 'list') { @@ -1072,7 +1084,7 @@ export function FileManager({ projectId }: FileManagerProps) { // Handle folder double click const handleFolderOpen = (folder: FileItem) => { if (viewMode === 'grid') { - setCurrentPath([...currentPath, folder.name]); + setCurrentPath([...currentPath, { id: folder.id, name: folder.name }]); setCurrentParentId(folder.id); } else { // In tree view, expand/collapse @@ -1117,6 +1129,7 @@ export function FileManager({ projectId }: FileManagerProps) { } else { setCurrentPath(currentPath.slice(0, index + 1)); // Need to update parentId logic + setCurrentParentId(currentPath[index].id); } }; @@ -1269,11 +1282,11 @@ export function FileManager({ projectId }: FileManagerProps) { Home - {currentPath.map((path, index) => ( + {currentPath.map((seg, index) => ( - navigateToPath(index)}> - {path} + navigateToPath(index)}> + {seg.name} ))} diff --git a/config/vendorInvestigationsColumnsConfig.ts b/config/vendorInvestigationsColumnsConfig.ts index 1fab1de6..ab5291a4 100644 --- a/config/vendorInvestigationsColumnsConfig.ts +++ b/config/vendorInvestigationsColumnsConfig.ts @@ -29,6 +29,7 @@ export type VendorInvestigationsViewRaw = { // PQ 정보 pqItems: string | null | Array<{itemCode: string, itemName: string}> + pqNumber: string | null hasAttachments: boolean @@ -98,6 +99,12 @@ export const vendorInvestigationsColumnsConfig: VendorInvestigationsColumnConfig excelHeader: "실사방법", group: "실사", }, + { + id: "pqNumber", + label: "PQ 번호", + excelHeader: "PQ 번호", + group: "실사", + }, { id: "pqItems", label: "실사품목", diff --git a/db/schema/pq.ts b/db/schema/pq.ts index 11d55473..a9d92953 100644 --- a/db/schema/pq.ts +++ b/db/schema/pq.ts @@ -419,6 +419,7 @@ export const vendorInvestigationsView = pgView( // PQ 정보 pqItems: vendorPQSubmissions.pqItems, + pqNumber: vendorPQSubmissions.pqNumber, // User names and emails instead of just IDs requesterName: sql`requester.name`.as("requesterName"), diff --git a/lib/approval-template/table/approval-template-table-toolbar-actions.tsx b/lib/approval-template/table/approval-template-table-toolbar-actions.tsx index 62754cc1..4fa4b394 100644 --- a/lib/approval-template/table/approval-template-table-toolbar-actions.tsx +++ b/lib/approval-template/table/approval-template-table-toolbar-actions.tsx @@ -25,53 +25,6 @@ export function ApprovalTemplateTableToolbarActions({ const selectedRows = table.getFilteredSelectedRowModel().rows const selectedTemplates = selectedRows.map((row) => row.original) - // CSV 내보내기 - const exportToCsv = React.useCallback(() => { - const headers = [ - "이름", - "제목", - "카테고리", - "생성일", - "수정일", - ] - - const csvData = [ - headers, - ...table.getFilteredRowModel().rows.map((row) => { - const t = row.original - return [ - t.name, - t.subject, - t.category ?? "-", - new Date(t.createdAt).toLocaleDateString("ko-KR"), - new Date(t.updatedAt).toLocaleDateString("ko-KR"), - ] - }), - ] - - const csvContent = csvData - .map((row) => row.map((field) => `"${field}"`).join(",")) - .join("\n") - - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) - const link = document.createElement("a") - - if (link.download !== undefined) { - const url = URL.createObjectURL(blob) - link.setAttribute("href", url) - link.setAttribute( - "download", - `approval_templates_${new Date().toISOString().split("T")[0]}.csv`, - ) - link.style.visibility = "hidden" - document.body.appendChild(link) - link.click() - document.body.removeChild(link) - } - - toast.success("템플릿 목록이 CSV로 내보내졌습니다.") - }, [table]) - return (
{/* 카테고리 관리 버튼 */} @@ -92,12 +45,6 @@ export function ApprovalTemplateTableToolbarActions({ 새 템플릿 - {/* CSV 내보내기 */} - - {/* 일괄 삭제 */} {selectedTemplates.length > 0 && ( <> diff --git a/lib/mail/templates/site-visit-request.hbs b/lib/mail/templates/site-visit-request.hbs index 12c05326..b2cc72b9 100644 --- a/lib/mail/templates/site-visit-request.hbs +++ b/lib/mail/templates/site-visit-request.hbs @@ -121,7 +121,7 @@
수신: - {{vendorName}} {{vendorContactName}} 귀하 + {{vendorName}} {{vendorEmail}} 귀하
발신: @@ -136,14 +136,11 @@

- 당사에선 귀사와의 정기적 거래를 위하여 귀사가 당사의 기준에 적합한 협력업체인지를 검토하기 위하여
- 귀사의 실 제작 공장을 직접 방문하여 점검하는 방문실사를 진행하고자 합니다. + 귀사와 거래 전 당사와 거래 가능 여부를 확인하고자 귀사의 실 제작 공장(혹은 지정 장소)을 방문하여 거래 가능 기준 준수 여부를 점검하고자 합니다.

- 방문실사를 위하여 다음과 같이 관련정보 및 요청정보/자료를 전달드리오니
- 메일 발신일 기준 C/D +7일 이내에 정보 입력 및 자료를 제출하시어
- 당사에서 귀사의 실 제작 공장 방문을 미리 준비할 수 있도록 적극적인 협조 부탁드립니다. + 방문 및 점검을 위하여 다음과 같이 관련 정보를 전달드림과 동시에 필요 정보와 자료를 요청 드리오니 하기 제출 마감일(혹은 요청 실사 시작일 중 먼저 도래하는 날) 이내로 제출하시어 양사 간 원활한 업무 진행이 될 수 있도록 적극적인 협조 부탁드립니다.

@@ -176,8 +173,17 @@
+ {{#if investigationAddress}}
-
3. 삼성중공업 실사 참석 예정 부문
+
3. 실사 주소
+
+ {{investigationAddress}} +
+
+ {{/if}} + +
+
{{#if investigationAddress}}4{{else}}3{{/if}}. 삼성중공업 실사 참석 인원 정보
{{#if shiAttendees}}
    {{#each shiAttendees}} @@ -198,7 +204,7 @@
-
4. 협력업체 요청정보 및 자료
+
{{#if investigationAddress}}5{{else}}4{{/if}}. 협력업체 요청정보 및 자료
    {{#each vendorRequests}}
  • {{this}}
  • @@ -213,7 +219,7 @@ {{#if additionalRequests}}
    -
    5. 추가 요청사항
    +
    {{#if investigationAddress}}6{{else}}5{{/if}}. 추가 요청사항
    {{additionalRequests}}
    diff --git a/lib/mail/templates/supplement-document-request.hbs b/lib/mail/templates/supplement-document-request.hbs new file mode 100644 index 00000000..2e16773a --- /dev/null +++ b/lib/mail/templates/supplement-document-request.hbs @@ -0,0 +1,207 @@ + + + + + + 보완 서류제출 요청 + + + + + + + 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} - - )} - /> - ))} -
    - {/* ( - - 기타 요청사항 - -