summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/partners/pq_new/page.tsx6
-rw-r--r--components/file-manager/FileManager.tsx25
-rw-r--r--config/vendorInvestigationsColumnsConfig.ts7
-rw-r--r--db/schema/pq.ts1
-rw-r--r--lib/approval-template/table/approval-template-table-toolbar-actions.tsx53
-rw-r--r--lib/mail/templates/site-visit-request.hbs24
-rw-r--r--lib/mail/templates/supplement-document-request.hbs207
-rw-r--r--lib/pq/pq-review-table-new/site-visit-dialog.tsx795
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx99
-rw-r--r--lib/pq/pq-review-table-new/vendors-table.tsx32
-rw-r--r--lib/pq/service.ts5
-rw-r--r--lib/rfq-last/service.ts229
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx59
-rw-r--r--lib/rfq-last/table/update-general-rfq-dialog.tsx749
-rw-r--r--lib/site-visit/client-site-visit-wrapper.tsx21
-rw-r--r--lib/site-visit/service.ts239
-rw-r--r--lib/site-visit/shi-attendees-dialog.tsx79
-rw-r--r--lib/site-visit/vendor-info-view-dialog.tsx582
-rw-r--r--lib/vendor-investigation/service.ts198
-rw-r--r--lib/vendor-investigation/table/change-qm-manager-dialog.tsx183
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx24
-rw-r--r--lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx50
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx28
23 files changed, 3031 insertions, 664 deletions
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() {
<TableHeader>
<TableRow>
<TableHead>유형</TableHead>
+ <TableHead>PQ 번호</TableHead>
<TableHead>프로젝트</TableHead>
<TableHead>상태</TableHead>
<TableHead>요청일</TableHead>
@@ -213,7 +214,7 @@ export default async function PQListPage() {
<TableBody>
{pqList.length === 0 ? (
<TableRow>
- <TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
+ <TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
요청된 PQ가 없습니다.
</TableCell>
</TableRow>
@@ -236,6 +237,9 @@ export default async function PQListPage() {
</Badge>
</TableCell>
<TableCell>
+ {pq.pqNumber || "-"}
+ </TableCell>
+ <TableCell>
{pq.projectName || "-"}
</TableCell>
<TableCell>
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<FileItem[]>([]);
const [treeItems, setTreeItems] = useState<FileItem[]>([]);
- const [currentPath, setCurrentPath] = useState<string[]>([]);
+ const [currentPath, setCurrentPath] = useState<{ id: string; name: string }[]>([]);
const [currentParentId, setCurrentParentId] = useState<string | null>(null);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(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
</BreadcrumbLink>
</BreadcrumbItem>
- {currentPath.map((path, index) => (
+ {currentPath.map((seg, index) => (
<BreadcrumbItem key={index}>
<ChevronRight className="h-4 w-4" />
- <BreadcrumbLink onClick={() => navigateToPath(index)}>
- {path}
+ <BreadcrumbLink onClick={() => navigateToPath(index)}>
+ {seg.name}
</BreadcrumbLink>
</BreadcrumbItem>
))}
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
@@ -99,6 +100,12 @@ export const vendorInvestigationsColumnsConfig: VendorInvestigationsColumnConfig
group: "실사",
},
{
+ id: "pqNumber",
+ label: "PQ 번호",
+ excelHeader: "PQ 번호",
+ group: "실사",
+ },
+ {
id: "pqItems",
label: "실사품목",
excelHeader: "실사품목",
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<string>`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 (
<div className="flex items-center gap-2">
{/* 카테고리 관리 버튼 */}
@@ -92,12 +45,6 @@ export function ApprovalTemplateTableToolbarActions({
새 템플릿
</Button>
- {/* CSV 내보내기 */}
- <Button variant="outline" size="sm" onClick={exportToCsv}>
- <Download className="mr-2 size-4" aria-hidden="true" />
- 내보내기
- </Button>
-
{/* 일괄 삭제 */}
{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 @@
<div class="company-info">
<div style="margin-bottom: 15px;">
<span class="info-label">수신:</span>
- <span class="info-value">{{vendorName}} {{vendorContactName}} 귀하</span>
+ <span class="info-value">{{vendorName}} {{vendorEmail}} 귀하</span>
</div>
<div>
<span class="info-label">발신:</span>
@@ -136,14 +136,11 @@
<!-- 본문 -->
<p style="font-size: 16px; margin-bottom: 20px;">
- 당사에선 귀사와의 정기적 거래를 위하여 귀사가 당사의 기준에 적합한 협력업체인지를 검토하기 위하여<br>
- 귀사의 실 제작 공장을 직접 방문하여 점검하는 방문실사를 진행하고자 합니다.
+ 귀사와 거래 전 당사와 거래 가능 여부를 확인하고자 귀사의 실 제작 공장(혹은 지정 장소)을 방문하여 거래 가능 기준 준수 여부를 점검하고자 합니다.
</p>
<p style="font-size: 16px; margin-bottom: 20px;">
- 방문실사를 위하여 다음과 같이 관련정보 및 요청정보/자료를 전달드리오니<br>
- 메일 발신일 기준 C/D +7일 이내에 정보 입력 및 자료를 제출하시어<br>
- 당사에서 귀사의 실 제작 공장 방문을 미리 준비할 수 있도록 적극적인 협조 부탁드립니다.
+ 방문 및 점검을 위하여 다음과 같이 관련 정보를 전달드림과 동시에 필요 정보와 자료를 요청 드리오니 하기 제출 마감일(혹은 요청 실사 시작일 중 먼저 도래하는 날) 이내로 제출하시어 양사 간 원활한 업무 진행이 될 수 있도록 적극적인 협조 부탁드립니다.
</p>
<!-- 마감일 안내 -->
@@ -176,8 +173,17 @@
</div>
</div>
+ {{#if investigationAddress}}
<div class="section">
- <div class="section-title">3. 삼성중공업 실사 참석 예정 부문</div>
+ <div class="section-title">3. 실사 주소</div>
+ <div class="info-item">
+ <span class="info-value">{{investigationAddress}}</span>
+ </div>
+ </div>
+ {{/if}}
+
+ <div class="section">
+ <div class="section-title">{{#if investigationAddress}}4{{else}}3{{/if}}. 삼성중공업 실사 참석 인원 정보</div>
{{#if shiAttendees}}
<ul class="attendees-list">
{{#each shiAttendees}}
@@ -198,7 +204,7 @@
</div>
<div class="section">
- <div class="section-title">4. 협력업체 요청정보 및 자료</div>
+ <div class="section-title">{{#if investigationAddress}}5{{else}}4{{/if}}. 협력업체 요청정보 및 자료</div>
<ul class="request-items">
{{#each vendorRequests}}
<li>{{this}}</li>
@@ -213,7 +219,7 @@
{{#if additionalRequests}}
<div class="section">
- <div class="section-title">5. 추가 요청사항</div>
+ <div class="section-title">{{#if investigationAddress}}6{{else}}5{{/if}}. 추가 요청사항</div>
<div class="info-item">
<span class="info-value">{{additionalRequests}}</span>
</div>
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 @@
+<!DOCTYPE html>
+<html lang="ko">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>보완 서류제출 요청</title>
+ <style>
+ body {
+ margin: 0 !important;
+ padding: 20px !important;
+ background-color: #f4f4f4;
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ line-height: 1.6;
+ }
+ .email-container {
+ max-width: 600px;
+ margin: 0 auto;
+ background-color: #ffffff;
+ padding: 30px;
+ border-radius: 8px;
+ box-shadow: 0 2px 10px rgba(0,0,0,0.1);
+ }
+ .header {
+ border-bottom: 2px solid #163CC4;
+ padding-bottom: 20px;
+ margin-bottom: 30px;
+ }
+ .company-info {
+ background-color: #f8f9fa;
+ padding: 20px;
+ border-radius: 6px;
+ margin: 20px 0;
+ border-left: 4px solid #163CC4;
+ }
+ .section {
+ margin: 20px 0;
+ }
+ .section-title {
+ font-weight: bold;
+ color: #163CC4;
+ margin-bottom: 10px;
+ font-size: 16px;
+ }
+ .info-item {
+ margin: 8px 0;
+ padding-left: 20px;
+ }
+ .info-label {
+ font-weight: bold;
+ color: #374151;
+ }
+ .info-value {
+ color: #1f2937;
+ }
+ .request-items {
+ list-style: none;
+ padding-left: 20px;
+ }
+ .request-items li {
+ margin: 8px 0;
+ padding-left: 15px;
+ position: relative;
+ }
+ .request-items li:before {
+ content: "○";
+ color: #163CC4;
+ font-weight: bold;
+ position: absolute;
+ left: 0;
+ }
+ .footer {
+ margin-top: 40px;
+ padding-top: 20px;
+ border-top: 1px solid #e5e7eb;
+ text-align: center;
+ color: #6b7280;
+ font-size: 14px;
+ }
+ .deadline {
+ background-color: #fef3c7;
+ border: 1px solid #f59e0b;
+ padding: 15px;
+ border-radius: 6px;
+ margin: 20px 0;
+ }
+ .deadline strong {
+ color: #d97706;
+ }
+ </style>
+</head>
+<body>
+ <div class="email-container">
+ <!-- 헤더 -->
+ <div class="header">
+ <table width="100%" cellpadding="0" cellspacing="0">
+ <tr>
+ <td align="center">
+ <span style="display: block; text-align: left; color: #163CC4; font-weight: bold; font-size: 32px;">eVCP</span>
+ </td>
+ </tr>
+ </table>
+ </div>
+
+ <!-- 수신/발신 정보 -->
+ <div class="company-info">
+ <div style="margin-bottom: 15px;">
+ <span class="info-label">수신:</span>
+ <span class="info-value">{{vendorName}} {{vendorEmail}} 귀하</span>
+ </div>
+ <div>
+ <span class="info-label">발신:</span>
+ <span class="info-value">{{requesterName}} {{requesterTitle}} ({{requesterEmail}})</span>
+ </div>
+ </div>
+
+ <!-- 인사말 -->
+ <p style="font-size: 16px; margin-bottom: 20px;">
+ 귀사 일익 번창하심을 기원합니다.
+ </p>
+
+ <!-- 본문 -->
+ <p style="font-size: 16px; margin-bottom: 20px;">
+ 귀사와 거래 전 당사와 거래 가능 여부를 확인하고자 귀사의 실 제작 공장(혹은 지정 장소)을 방문하여 거래 가능 기준 준수 여부를 점검하고자 합니다.
+ </p>
+
+ <p style="font-size: 16px; margin-bottom: 20px;">
+ 방문 및 점검을 위하여 다음과 같이 관련 정보를 전달드림과 동시에 필요 정보와 자료를 요청 드리오니 하기 제출 마감일(혹은 요청 실사 시작일 중 먼저 도래하는 날) 이내로 제출하시어 양사 간 원활한 업무 진행이 될 수 있도록 적극적인 협조 부탁드립니다.
+ </p>
+
+ {{#if deadlineDate}}
+ <!-- 마감일 안내 -->
+ <div class="deadline">
+ <strong>📅 제출 마감일: {{deadlineDate}}</strong>
+ </div>
+ {{/if}}
+
+ <!-- 구분선 -->
+ <div style="text-align: center; margin: 30px 0;">
+ <span style="font-weight: bold; font-size: 18px; color: #163CC4;">- 다 음 -</span>
+ </div>
+
+ <!-- 보완 요청 서류 -->
+ <div class="section">
+ <div class="section-title">1. 보완 요청 서류</div>
+ {{#if requiredDocuments}}
+ <ul class="request-items">
+ {{#each requiredDocuments}}
+ <li>{{this}}</li>
+ {{/each}}
+ </ul>
+ {{else}}
+ <div class="info-item">
+ <span class="info-value">요청 서류가 없습니다.</span>
+ </div>
+ {{/if}}
+ </div>
+
+ {{#if additionalRequests}}
+ <div class="section">
+ <div class="section-title">2. 추가 요청사항</div>
+ <div class="info-item">
+ <span class="info-value">{{additionalRequests}}</span>
+ </div>
+ </div>
+ {{/if}}
+
+ <!-- 문의사항 -->
+ <div style="margin: 30px 0; padding: 20px; background-color: #f8f9fa; border-radius: 6px;">
+ <p style="font-size: 16px; margin: 0;">
+ 상기 내역에 대해 문의사항이 있을 경우 구매 담당자에게 연락 바랍니다.
+ </p>
+ </div>
+
+ <!-- 마무리 -->
+ <p style="font-size: 16px; margin-bottom: 20px;">감사합니다.</p>
+
+ <!-- 발신자 정보 -->
+ <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb;">
+ <p style="font-size: 14px; margin: 5px 0; color: #374151;">
+ {{requesterName}} / {{requesterTitle}} / {{requesterEmail}}
+ </p>
+ <p style="font-size: 14px; margin: 5px 0; color: #374151;">
+ SAMSUNG HEAVY INDUSTRIES CO., LTD.
+ </p>
+ <p style="font-size: 14px; margin: 5px 0; color: #374151;">
+ 80, Jangpyeong 3-ro, Geoje-si, Gyeongsangnam-do, Republic of Korea, 53261
+ </p>
+ </div>
+
+ <!-- 포털 링크 -->
+ {{#if portalUrl}}
+ <div style="text-align: center; margin: 30px 0;">
+ <a href="{{portalUrl}}" target="_blank" style="display:inline-block; background-color:#163CC4; color:#ffffff; padding:12px 24px; text-decoration:none; border-radius:6px; font-weight:bold;">
+ 협력업체 정보 입력하기
+ </a>
+ </div>
+ {{/if}}
+
+ <!-- 푸터 -->
+ <div class="footer">
+ <p style="margin: 4px 0;">© {{currentYear}} EVCP. All rights reserved.</p>
+ <p style="margin: 4px 0;">이 메일은 자동으로 발송되었습니다. 회신하지 마세요.</p>
+ </div>
+ </div>
+</body>
+</html>
+
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<typeof siteVisitRequestSchema>
+export type SiteVisitRequestFormValues = z.infer<typeof siteVisitRequestSchema>
+
+// 사용자 타입 정의
+interface SiteVisitUser {
+ id: number;
+ name: string;
+ email: string;
+ deptName: string | null;
+}
+
+// 참석자 섹션 컴포넌트
+function AttendeeSection({
+ form,
+ itemKey,
+ label,
+ isPending,
+}: {
+ form: ReturnType<typeof useForm<SiteVisitRequestFormValues>>
+ 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<SiteVisitUser[]>([]);
+ 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 (
+ <div className="border rounded-lg p-4 space-y-4">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center space-x-3">
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${itemKey}.checked` as any}
+ render={({ field }) => (
+ <FormItem className="flex items-center space-x-2 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value as boolean}
+ onCheckedChange={(checked) => {
+ field.onChange(checked);
+ // 체크 해제 시 참석자 목록 초기화
+ if (!checked) {
+ form.setValue(`shiAttendees.${itemKey}.attendees` as any, []);
+ setIsPopoverOpen(false);
+ }
+ }}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormLabel className="font-medium text-base">{label}</FormLabel>
+ </FormItem>
+ )}
+ />
+ </div>
+ {isChecked && (
+ <div className="flex items-center gap-2">
+ <span className="text-sm text-muted-foreground">
+ 참석인원: {fields.length}명
+ </span>
+ </div>
+ )}
+ </div>
+
+ {isChecked && (
+ <div className="space-y-3">
+ {/* 사용자 선택 UI */}
+ <Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full justify-start text-left font-normal"
+ disabled={isPending}
+ >
+ <Search className="mr-2 h-4 w-4" />
+ 이름 또는 이메일로 검색...
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="w-[400px] p-0" align="start">
+ <Command>
+ <CommandInput
+ placeholder="이름 또는 이메일로 검색..."
+ value={searchQuery}
+ onValueChange={setSearchQuery}
+ />
+ <CommandList>
+ <CommandEmpty>
+ {isLoadingUsers ? "로딩 중..." : "검색 결과가 없습니다."}
+ </CommandEmpty>
+ <CommandGroup>
+ {users.map((user) => {
+ const selected = isUserSelected(user.email);
+ return (
+ <CommandItem
+ key={user.id}
+ value={`${user.name} ${user.email}`}
+ onSelect={() => handleUserSelect(user)}
+ className="cursor-pointer"
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ selected ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex flex-col flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium truncate">
+ {user.name}
+ </span>
+ {user.deptName && (
+ <span className="text-xs text-muted-foreground truncate">
+ ({user.deptName})
+ </span>
+ )}
+ </div>
+ <span className="text-xs text-muted-foreground truncate">
+ {user.email}
+ </span>
+ </div>
+ </CommandItem>
+ );
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+
+ {/* 선택된 사용자 목록 */}
+ {fields.length > 0 && (
+ <div className="space-y-2">
+ {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 (
+ <div
+ key={fieldItem.id}
+ className="flex items-center justify-between p-3 bg-muted/50 rounded-md"
+ >
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{attendee.name}</span>
+ {attendee.department && (
+ <span className="text-sm text-muted-foreground">
+ ({attendee.department})
+ </span>
+ )}
+ </div>
+ <div className="text-sm text-muted-foreground truncate">
+ {attendee.email}
+ </div>
+ </div>
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ onClick={() => remove(index)}
+ disabled={isPending}
+ className="h-8 w-8 flex-shrink-0"
+ >
+ <Trash2 className="h-4 w-4 text-red-500" />
+ </Button>
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+ );
+}
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<SiteVisitRequestFormValues>({
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({
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
- <DialogTitle>{isReinspection ? "재실사 요청 생성" : "방문실사 요청 생성"}</DialogTitle>
+ <DialogTitle>{isReinspection ? "재실사 요청 생성" : "방문실사 요청 생성"} <Badge variant="outline">
+ {getInvestigationMethodLabel(investigation.investigationMethod || "")}
+ </Badge></DialogTitle>
<DialogDescription>
{isReinspection
? "협력업체에 재실사 요청을 생성하고, 협력업체가 입력할 정보 항목을 설정합니다."
@@ -329,6 +671,16 @@ export function SiteVisitDialog({
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* QM 의견 (있는 경우에만 표시) */}
+
+ <div>
+ <FormLabel className="text-sm font-medium">QM 의견</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{investigation.investigationNotes}</p>
+ </div>
+ </div>
+
+
{/* 대상업체 정보 */}
<div className="grid grid-cols-2 gap-4">
<div>
@@ -362,31 +714,46 @@ export function SiteVisitDialog({
{/* 실사방법 */}
- <div>
+ {/* <div>
<FormLabel className="text-sm font-medium">실사방법</FormLabel>
<div className="mt-1 p-3 bg-muted rounded-md">
<Badge variant="outline">
{getInvestigationMethodLabel(investigation.investigationMethod || "")}
</Badge>
</div>
- </div>
-
+ </div> */}
+ <div className="grid grid-cols-3 gap-4">
{/* 실사기간 */}
<FormField
control={form.control}
name="inspectionDuration"
render={({ field }) => (
- <FormItem>
+ <FormItem className="flex flex-col">
<FormLabel>실사기간 (W/D 기준)</FormLabel>
<div className="flex items-center gap-2">
<FormControl>
<Input
type="number"
- step="0.5"
- min="0.5"
- placeholder="1.5"
+ step="1"
+ min="1"
+ placeholder="1"
{...field}
- onChange={(e) => 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({
/>
{/* 실사요청일 */}
- <div className="grid grid-cols-2 gap-4">
+
<FormField
control={form.control}
name="requestedStartDate"
@@ -427,7 +794,25 @@ export function SiteVisitDialog({
<Calendar
mode="single"
selected={field.value}
- onSelect={field.onChange}
+ onSelect={(date) => {
+ 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({
<FormField
control={form.control}
name="requestedEndDate"
- render={({ field }) => (
- <FormItem className="flex flex-col">
- <FormLabel>실사 종료일</FormLabel>
- <Popover>
- <PopoverTrigger asChild>
- <FormControl>
- <Button
- variant={"outline"}
- className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
- disabled={isPending}
- >
- {field.value ? (
- format(field.value, "yyyy년 MM월 dd일")
- ) : (
- <span>종료일을 선택하세요</span>
- )}
- <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
- </Button>
- </FormControl>
- </PopoverTrigger>
- <PopoverContent className="w-auto p-0" align="start">
- <Calendar
- mode="single"
- selected={field.value}
- onSelect={field.onChange}
- disabled={(date) => date < new Date()}
- initialFocus
- />
- </PopoverContent>
- </Popover>
- <FormMessage />
- </FormItem>
- )}
+ render={({ field }) => {
+ const startDate = form.watch('requestedStartDate');
+ return (
+ <FormItem className="flex flex-col">
+ <FormLabel>실사 종료일</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant={"outline"}
+ className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`}
+ disabled={isPending}
+ >
+ {field.value ? (
+ format(field.value, "yyyy년 MM월 dd일")
+ ) : (
+ <span>종료일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={(date) => {
+ 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
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ );
+ }}
/>
</div>
{/* SHI 실사참석 예정부문 */}
<div>
- <FormLabel className="text-sm font-medium">SHI 실사참석 예정부문 ※ 필수값</FormLabel>
+ <FormLabel className="text-sm font-medium">SHI 실사 참석 인원 정보 (*)</FormLabel>
<div className="text-sm text-muted-foreground mb-4">
삼성중공업에 어떤 부문의 담당자가 몇 명 실사 참석 예정인지에 대한 정보를 입력하세요.
+ <br />부서 상관없이 최소 1명 이상 필수입니다.
</div>
- <div className="border rounded-lg overflow-hidden">
- <Table>
- <TableHeader>
- <TableRow className="bg-muted/50">
- <TableHead className="w-[100px]">참석여부</TableHead>
- <TableHead className="w-[120px]">부문</TableHead>
- <TableHead className="w-[100px]">참석인원</TableHead>
- <TableHead>참석자 정보</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {[
- { key: "technicalSales", label: "기술영업" },
- { key: "design", label: "설계" },
- { key: "procurement", label: "구매" },
- { key: "quality", label: "품질" },
- { key: "production", label: "생산" },
- { key: "commissioning", label: "시운전" },
- { key: "other", label: "기타" },
- ].map((item) => (
- <TableRow key={item.key}>
- <TableCell>
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.checked` as any}
- render={({ field }) => (
- <FormItem className="flex items-center space-x-2 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value as boolean}
- onCheckedChange={field.onChange}
- disabled={isPending}
- />
- </FormControl>
- </FormItem>
- )}
- />
- </TableCell>
- <TableCell>
- <span className="font-medium">{item.label}</span>
- </TableCell>
- <TableCell>
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.count` as any}
- render={({ field }) => (
- <FormItem className="space-y-0">
- <div className="flex items-center space-x-2">
- <FormControl>
- <Input
- type="number"
- min="0"
- placeholder="0"
- value={field.value as number}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
- disabled={isPending}
- className="w-16 h-8"
- />
- </FormControl>
- <span className="text-xs text-muted-foreground">명</span>
- </div>
- <FormMessage />
- </FormItem>
- )}
- />
- </TableCell>
- <TableCell>
- <FormField
- control={form.control}
- name={`shiAttendees.${item.key}.details` as any}
- render={({ field }) => (
- <FormItem className="space-y-0">
- <FormControl>
- <Input
- placeholder="부서 및 이름 등"
- value={field.value as string}
- onChange={field.onChange}
- disabled={isPending}
- className="h-8"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </TableCell>
- </TableRow>
- ))}
- </TableBody>
- </Table>
+ <div className="space-y-4">
+ <AttendeeSection
+ form={form}
+ itemKey="technicalSales"
+ label="기술영업"
+ isPending={isPending}
+ />
+ <AttendeeSection
+ form={form}
+ itemKey="design"
+ label="설계"
+ isPending={isPending}
+ />
+ <AttendeeSection
+ form={form}
+ itemKey="procurement"
+ label="구매"
+ isPending={isPending}
+ />
+ <AttendeeSection
+ form={form}
+ itemKey="quality"
+ label="품질"
+ isPending={isPending}
+ />
+ <AttendeeSection
+ form={form}
+ itemKey="production"
+ label="생산"
+ isPending={isPending}
+ />
+ <AttendeeSection
+ form={form}
+ itemKey="commissioning"
+ label="시운전"
+ isPending={isPending}
+ />
+ <AttendeeSection
+ form={form}
+ itemKey="other"
+ label="기타"
+ isPending={isPending}
+ />
</div>
{/* 전체 참석자 상세정보 */}
@@ -597,63 +955,6 @@ export function SiteVisitDialog({
/>
</div>
- {/* 협력업체 요청정보 및 자료 */}
- {/* <div>
- <FormLabel className="text-sm font-medium">협력업체 요청정보 및 자료</FormLabel>
- <div className="text-sm text-muted-foreground mb-2">
- 협력업체에게 요청할 정보를 선택하세요.
- </div>
- <div className="mt-2 space-y-2">
- {[
- { 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) => (
- <FormField
- key={item.key}
- control={form.control}
- name={`vendorRequests.${item.key}` as any}
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={!!field.value}
- onCheckedChange={field.onChange}
- disabled={isPending}
- />
- </FormControl>
- <FormLabel className="text-sm font-normal">{item.label}</FormLabel>
- </FormItem>
- )}
- />
- ))}
- </div>
- {/* <FormField
- control={form.control}
- name="otherVendorRequests"
- render={({ field }) => (
- <FormItem className="mt-4">
- <FormLabel>기타 요청사항</FormLabel>
- <FormControl>
- <Textarea
- placeholder="기타 요청사항을 입력하세요"
- {...field}
- disabled={isPending}
- className="min-h-[60px]"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div> */}
-
{/* 추가 요청사항 */}
<FormField
control={form.control}
diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
index a68d9b23..f93959a6 100644
--- a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx
@@ -16,6 +16,7 @@ import {
getQMManagers
} from "@/lib/pq/service"
import { SiteVisitDialog } from "./site-visit-dialog"
+import type { SiteVisitRequestFormValues } from "./site-visit-dialog"
import { RequestInvestigationDialog } from "./request-investigation-dialog"
import { CancelInvestigationDialog, ReRequestInvestigationDialog } from "./cancel-investigation-dialog"
import { SendResultsDialog } from "./send-results-dialog"
@@ -444,12 +445,10 @@ const handleOpenRequestDialog = async () => {
}
// 재실사 요청 처리
- const handleRequestReinspection = async (data: {
- qmManagerId: number,
- forecastedAt: Date,
- investigationAddress: string,
- investigationNotes?: string
- }) => {
+ const handleRequestReinspection = async (
+ data: SiteVisitRequestFormValues,
+ attachments?: File[]
+ ) => {
try {
// 보완-재실사 대상 실사만 필터링
const supplementReinspectInvestigations = selectedRows.filter(row =>
@@ -463,23 +462,27 @@ const handleOpenRequestDialog = async () => {
}
// 첫 번째 대상 실사로 재실사 요청 생성
- const targetInvestigation = supplementReinspectInvestigations[0].original.investigation!;
+ const targetRow = supplementReinspectInvestigations[0].original;
+ const targetInvestigation = targetRow.investigation!;
const { requestSupplementReinspectionAction } = await import('@/lib/vendor-investigation/service');
+ // SiteVisitRequestFormValues를 requestSupplementReinspectionAction 형식으로 변환
+ // shiAttendees는 그대로 전달 (새로운 형식: {checked, attendees})
const result = await requestSupplementReinspectionAction({
investigationId: targetInvestigation.id,
siteVisitData: {
- inspectionDuration: 1.0, // 기본 1일
- requestedStartDate: data.forecastedAt,
- requestedEndDate: new Date(data.forecastedAt.getTime() + 24 * 60 * 60 * 1000), // 1일 후
- shiAttendees: {},
- vendorRequests: {},
- additionalRequests: data.investigationNotes || "보완을 위한 재실사 요청입니다.",
- }
+ inspectionDuration: data.inspectionDuration,
+ requestedStartDate: data.requestedStartDate,
+ requestedEndDate: data.requestedEndDate,
+ shiAttendees: data.shiAttendees || {},
+ vendorRequests: data.vendorRequests || {},
+ additionalRequests: data.additionalRequests || "",
+ },
});
if (result.success) {
toast.success("재실사 요청이 생성되었습니다.");
+ setIsReinspectionDialogOpen(false);
window.location.reload();
} else {
toast.error(result.error || "재실사 요청 생성 중 오류가 발생했습니다.");
@@ -563,6 +566,16 @@ const handleOpenRequestDialog = async () => {
row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT"
).length
+ // 재실사 요청 가능 여부 확인 (방문실사평가 또는 제품검사평가만 가능)
+ const canRequestReinspection = selectedRows.some(row => {
+ const investigation = row.original.investigation
+ if (!investigation) return false
+ if (investigation.evaluationResult !== "SUPPLEMENT_REINSPECT") return false
+ const method = investigation.investigationMethod
+ // 서류평가 또는 구매자체평가는 재방문실사 불가
+ return method === "SITE_VISIT_EVAL" || method === "PRODUCT_INSPECTION"
+ })
+
// 미실사 PQ가 선택되었는지 확인
const hasNonInspectionPQ = selectedRows.some(row =>
row.original.type === "NON_INSPECTION"
@@ -720,9 +733,15 @@ const handleOpenRequestDialog = async () => {
disabled={
isLoading ||
selectedRows.length === 0 ||
- reinspectInvestigationsCount === 0
+ reinspectInvestigationsCount === 0 ||
+ !canRequestReinspection
}
className="gap-2"
+ title={
+ !canRequestReinspection && reinspectInvestigationsCount > 0
+ ? "재방문 실사 요청은 방문실사평가 또는 제품검사평가에만 가능합니다."
+ : undefined
+ }
>
<RefreshCw className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">재방문 실사 요청</span>
@@ -805,22 +824,40 @@ const handleOpenRequestDialog = async () => {
/>
{/* 재방문실사 요청 Dialog */}
- <SiteVisitDialog
- isOpen={isReinspectionDialogOpen}
- onClose={() => setIsReinspectionDialogOpen(false)}
- onSubmit={handleRequestReinspection}
- investigation={{
- id: 0, // 재실사용으로 0으로 설정 (기존 데이터 로드 안함)
- investigationMethod: "SITE_VISIT_EVAL",
- investigationAddress: "",
- vendorName: "재실사 대상",
- vendorCode: "N/A",
- projectName: "",
- projectCode: "",
- pqItems: null
- }}
- isReinspection={true}
- />
+ {(() => {
+ // 보완-재실사 대상 실사 찾기
+ const supplementReinspectInvestigations = selectedRows.filter(row =>
+ row.original.investigation &&
+ row.original.investigation.evaluationResult === "SUPPLEMENT_REINSPECT"
+ );
+
+ if (supplementReinspectInvestigations.length === 0) {
+ return null;
+ }
+
+ const targetRow = supplementReinspectInvestigations[0].original;
+ const targetInvestigation = targetRow.investigation!;
+
+ return (
+ <SiteVisitDialog
+ isOpen={isReinspectionDialogOpen}
+ onClose={() => setIsReinspectionDialogOpen(false)}
+ onSubmit={handleRequestReinspection}
+ investigation={{
+ id: targetInvestigation.id,
+ investigationMethod: targetInvestigation.investigationMethod || undefined,
+ investigationAddress: targetInvestigation.investigationAddress || undefined,
+ investigationNotes: targetInvestigation.investigationNotes || undefined,
+ vendorName: targetRow.vendorName,
+ vendorCode: targetRow.vendorCode,
+ projectName: targetRow.projectName || undefined,
+ projectCode: targetRow.projectCode || undefined,
+ pqItems: targetRow.pqItems || null,
+ }}
+ isReinspection={true}
+ />
+ );
+ })()}
{/* 결재 미리보기 Dialog - 실사 의뢰 */}
{session?.user && investigationFormData && (
diff --git a/lib/pq/pq-review-table-new/vendors-table.tsx b/lib/pq/pq-review-table-new/vendors-table.tsx
index e55da8c5..56bfb22c 100644
--- a/lib/pq/pq-review-table-new/vendors-table.tsx
+++ b/lib/pq/pq-review-table-new/vendors-table.tsx
@@ -42,6 +42,7 @@ export function PQSubmissionsTable({ promises, className }: PQSubmissionsTablePr
const [selectedInvestigation, setSelectedInvestigation] = React.useState<PQSubmission | null>(null)
const [isVendorInfoViewDialogOpen, setIsVendorInfoViewDialogOpen] = React.useState(false)
const [selectedSiteVisitRequestId, setSelectedSiteVisitRequestId] = React.useState<number | null>(null)
+ const [selectedInvestigationId, setSelectedInvestigationId] = React.useState<number | null>(null)
// 실사 정보 수정 다이얼로그 상태
const [isEditInvestigationDialogOpen, setIsEditInvestigationDialogOpen] = React.useState(false)
@@ -95,13 +96,23 @@ export function PQSubmissionsTable({ promises, className }: PQSubmissionsTablePr
inspectionDuration: number
requestedStartDate: Date
requestedEndDate: Date
- shiAttendees: Record<string, boolean>
+ shiAttendees: {
+ [key: string]: {
+ checked: boolean;
+ attendees: Array<{
+ name: string;
+ department?: string;
+ email?: string;
+ }>;
+ };
+ }
shiAttendeeDetails?: string
vendorRequests: Record<string, boolean>
otherVendorRequests?: string
additionalRequests?: string
}, attachments?: File[]) => {
try {
+ console.log("data", data)
const result = await createSiteVisitRequestAction({
investigationId: selectedInvestigation?.investigation?.id || 0,
...data,
@@ -123,14 +134,15 @@ export function PQSubmissionsTable({ promises, className }: PQSubmissionsTablePr
// 방문실사 다이얼로그 열기
const handleOpenSiteVisitDialog = async (investigation: PQSubmission) => {
try {
- // 기존 방문실사 요청이 있는지 확인
- const existingRequest = await getSiteVisitRequestAction(investigation.investigation?.id || 0)
-
- if (existingRequest.success && existingRequest.data) {
- toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
- return
- }
+ // // 기존 방문실사 요청이 있는지 확인
+ // const existingRequest = await getSiteVisitRequestAction(investigation.investigation?.id || 0)
+ // if (existingRequest.success && existingRequest.data) {
+ // toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ // return
+ // }
+
+ console.log("investigation", investigation)
setSelectedInvestigation(investigation)
setIsSiteVisitDialogOpen(true)
} catch (error) {
@@ -190,6 +202,7 @@ export function PQSubmissionsTable({ promises, className }: PQSubmissionsTablePr
} else if (rowAction?.type === "vendor-info-view") {
// 협력업체 정보 조회 다이얼로그 열기
setSelectedSiteVisitRequestId(rowAction.row.siteVisitRequestId || null)
+ setSelectedInvestigationId(rowAction.row.investigation?.id || null)
setIsVendorInfoViewDialogOpen(true)
setRowAction(null)
} else if (rowAction?.type === "update") {
@@ -450,6 +463,7 @@ export function PQSubmissionsTable({ promises, className }: PQSubmissionsTablePr
id: selectedInvestigation.investigation?.id || 0,
investigationMethod: selectedInvestigation.investigation?.investigationMethod || "",
investigationAddress: selectedInvestigation.investigation?.investigationAddress || "",
+ investigationNotes: selectedInvestigation.investigation?.investigationNotes || "",
vendorName: selectedInvestigation.vendorName,
vendorCode: selectedInvestigation.vendorCode,
projectName: selectedInvestigation.projectName || undefined,
@@ -465,8 +479,10 @@ export function PQSubmissionsTable({ promises, className }: PQSubmissionsTablePr
onClose={() => {
setIsVendorInfoViewDialogOpen(false)
setSelectedSiteVisitRequestId(null)
+ setSelectedInvestigationId(null)
}}
siteVisitRequestId={selectedSiteVisitRequestId}
+ investigationId={selectedInvestigationId}
/>
{/* 실사 정보 수정 다이얼로그 */}
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index 7cdbcafd..d3974964 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -1545,6 +1545,7 @@ export async function getAllPQsByVendorId(vendorId: number) {
id: vendorPQSubmissions.id,
type: vendorPQSubmissions.type,
status: vendorPQSubmissions.status,
+ pqNumber: vendorPQSubmissions.pqNumber,
projectId: vendorPQSubmissions.projectId,
projectName: projects.name,
createdAt: vendorPQSubmissions.createdAt,
@@ -3319,7 +3320,9 @@ export async function getQMManagers() {
.where(
and(
eq(users.isActive, true),
- ilike(users.deptName, "%품질경영팀(%")
+ ne(users.domain, "partners")
+ // ilike(users.deptName, "%품질경영팀(%")
+ // 테스트 간 임시제거 후 추가 예정(1103)
)
)
.orderBy(users.name);
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 8475aac0..2c1aa2ca 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -5515,4 +5515,233 @@ export async function getVendorDocumentConfirmStatus(
console.error("문서 확정 상태 조회 중 오류:", error);
return { isConfirmed: false, count: 0 };
}
+}
+
+// 일반견적 수정 입력 인터페이스
+interface UpdateGeneralRfqInput {
+ id: number; // 수정할 RFQ ID
+ rfqType: string;
+ rfqTitle: string;
+ dueDate: Date;
+ picUserId: number;
+ projectId?: number;
+ remark?: string;
+ items: Array<{
+ itemCode: string;
+ itemName: string;
+ materialCode?: string;
+ materialName?: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>;
+ updatedBy: number;
+}
+
+// 일반견적 수정 서버 액션
+export async function updateGeneralRfqAction(input: UpdateGeneralRfqInput) {
+ try {
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 1. 기존 RFQ 조회 (존재 확인 및 상태 확인)
+ const existingRfq = await tx
+ .select()
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, input.id))
+ .limit(1);
+
+ if (!existingRfq || existingRfq.length === 0) {
+ throw new Error("수정할 일반견적을 찾을 수 없습니다");
+ }
+
+ const rfq = existingRfq[0];
+
+ // 상태 검증 (RFQ 생성 상태만 수정 가능)
+ if (rfq.status !== "RFQ 생성") {
+ throw new Error("RFQ 생성 상태인 일반견적만 수정할 수 있습니다");
+ }
+
+ // 2. 구매 담당자 정보 조회
+ const picUser = await tx
+ .select({
+ name: users.name,
+ email: users.email,
+ userCode: users.userCode
+ })
+ .from(users)
+ .where(eq(users.id, input.picUserId))
+ .limit(1);
+
+ if (!picUser || picUser.length === 0) {
+ throw new Error("구매 담당자를 찾을 수 없습니다");
+ }
+
+ // 3. userCode 확인 (3자리)
+ const userCode = picUser[0].userCode;
+ if (!userCode || userCode.length !== 3) {
+ throw new Error("구매 담당자의 userCode가 올바르지 않습니다 (3자리 필요)");
+ }
+
+ // 4. 대표 아이템 정보 추출 (첫 번째 아이템)
+ const representativeItem = input.items[0];
+
+ // 5. rfqsLast 테이블 업데이트
+ const [updatedRfq] = await tx
+ .update(rfqsLast)
+ .set({
+ rfqType: input.rfqType,
+ rfqTitle: input.rfqTitle,
+ dueDate: input.dueDate,
+ projectId: input.projectId || null,
+ itemCode: representativeItem.itemCode,
+ itemName: representativeItem.itemName,
+ pic: input.picUserId,
+ picCode: userCode,
+ picName: picUser[0].name || '',
+ remark: input.remark || null,
+ updatedBy: input.updatedBy,
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, input.id))
+ .returning();
+
+ // 6. 기존 rfqPrItems 삭제 후 재삽입
+ await tx
+ .delete(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, input.id));
+
+ // 7. rfqPrItems 테이블에 아이템들 재삽입
+ const prItemsData = input.items.map((item, index) => ({
+ rfqsLastId: input.id,
+ rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ...
+ prItem: null, // 일반견적에서는 PR 아이템 번호를 null로 설정
+ prNo: null, // 일반견적에서는 PR 번호를 null로 설정
+
+ materialCode: item.materialCode || item.itemCode, // SAP 자재코드 (없으면 자재그룹코드 사용)
+ materialCategory: item.itemCode, // 자재그룹코드
+ materialDescription: item.materialName || item.itemName, // SAP 자재명 (없으면 자재그룹명 사용)
+ quantity: item.quantity,
+ uom: item.uom,
+
+ majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
+ remark: item.remark || null,
+ }));
+
+ await tx.insert(rfqPrItems).values(prItemsData);
+
+ return updatedRfq;
+ });
+
+ return {
+ success: true,
+ message: "일반견적이 성공적으로 수정되었습니다",
+ data: {
+ id: result.id,
+ rfqCode: result.rfqCode,
+ },
+ };
+
+ } catch (error) {
+ console.error("일반견적 수정 오류:", error);
+
+ if (error instanceof Error) {
+ return {
+ success: false,
+ error: error.message,
+ };
+ }
+
+ return {
+ success: false,
+ error: "일반견적 수정 중 오류가 발생했습니다",
+ };
+ }
+}
+
+// 일반견적 수정용 데이터 조회 함수
+export async function getGeneralRfqForUpdate(rfqId: number) {
+ try {
+ // RFQ 기본 정보 조회
+ const rfqData = await db
+ .select({
+ id: rfqsLast.id,
+ rfqCode: rfqsLast.rfqCode,
+ rfqType: rfqsLast.rfqType,
+ rfqTitle: rfqsLast.rfqTitle,
+ status: rfqsLast.status,
+ dueDate: rfqsLast.dueDate,
+ projectId: rfqsLast.projectId,
+ pic: rfqsLast.pic,
+ picCode: rfqsLast.picCode,
+ picName: rfqsLast.picName,
+ remark: rfqsLast.remark,
+ createdAt: rfqsLast.createdAt,
+ updatedAt: rfqsLast.updatedAt,
+ })
+ .from(rfqsLast)
+ .where(
+ and(
+ eq(rfqsLast.id, rfqId),
+ eq(rfqsLast.status, "RFQ 생성") // RFQ 생성 상태만 조회
+ )
+ )
+ .limit(1);
+
+ if (!rfqData || rfqData.length === 0) {
+ return {
+ success: false,
+ error: "수정할 일반견적을 찾을 수 없거나 수정할 수 없는 상태입니다",
+ };
+ }
+
+ const rfq = rfqData[0];
+
+ // RFQ 아이템들 조회
+ const items = await db
+ .select({
+ rfqItem: rfqPrItems.rfqItem,
+ materialCode: rfqPrItems.materialCode,
+ materialCategory: rfqPrItems.materialCategory,
+ materialDescription: rfqPrItems.materialDescription,
+ quantity: rfqPrItems.quantity,
+ uom: rfqPrItems.uom,
+ remark: rfqPrItems.remark,
+ })
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId))
+ .orderBy(rfqPrItems.rfqItem);
+
+ // 아이템 데이터를 폼 형식으로 변환
+ const formItems = items.map(item => ({
+ itemCode: item.materialCategory || "", // 자재그룹코드
+ itemName: item.materialDescription || "", // 자재그룹명
+ materialCode: item.materialCode || "", // SAP 자재코드
+ materialName: item.materialDescription || "", // SAP 자재명 (설명으로 사용)
+ quantity: Math.floor(Number(item.quantity)), // 소수점 제거
+ uom: item.uom,
+ remark: item.remark || "",
+ }));
+
+ return {
+ success: true,
+ data: {
+ id: rfq.id,
+ rfqCode: rfq.rfqCode,
+ rfqType: rfq.rfqType,
+ rfqTitle: rfq.rfqTitle,
+ dueDate: rfq.dueDate,
+ picUserId: rfq.pic,
+ projectId: rfq.projectId,
+ remark: rfq.remark,
+ items: formItems,
+ },
+ };
+
+ } catch (error) {
+ console.error("일반견적 조회 오류:", error);
+ return {
+ success: false,
+ error: "일반견적 조회 중 오류가 발생했습니다",
+ };
+ }
} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index 00c41402..148336fb 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -3,10 +3,11 @@
import * as React from "react";
import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
-import { Users, RefreshCw, FileDown, Plus } from "lucide-react";
+import { Users, RefreshCw, FileDown, Plus, Edit } from "lucide-react";
import { RfqsLastView } from "@/db/schema";
import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog";
import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; // 추가
+import { UpdateGeneralRfqDialog } from "./update-general-rfq-dialog"; // 수정용
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
@@ -21,12 +22,14 @@ interface RfqTableToolbarActionsProps<TData> {
onRefresh?: () => void;
}
-export function RfqTableToolbarActions<TData>({
- table,
+export function RfqTableToolbarActions<TData>({
+ table,
rfqCategory = "itb",
- onRefresh
+ onRefresh
}: RfqTableToolbarActionsProps<TData>) {
const [showAssignDialog, setShowAssignDialog] = React.useState(false);
+ const [showUpdateDialog, setShowUpdateDialog] = React.useState(false);
+ const [selectedRfqForUpdate, setSelectedRfqForUpdate] = React.useState<number | null>(null);
console.log(rfqCategory)
@@ -41,6 +44,9 @@ export function RfqTableToolbarActions<TData>({
(row.status === "RFQ 생성" || row.status === "구매담당지정")
);
+ // 수정 가능한 RFQ (general 카테고리에서 RFQ 생성 상태인 항목, 단일 선택만)
+ const updatableRfq = rfqCategory === "general" && rows.length === 1 && rows[0].status === "RFQ 생성" ? rows[0] : null;
+
return {
ids: rows.map(row => row.id),
codes: rows.map(row => row.rfqCode || ""),
@@ -51,9 +57,12 @@ export function RfqTableToolbarActions<TData>({
// 담당자 지정 가능한 ITB (상태가 "RFQ 생성" 또는 "구매담당지정"인 ITB)
assignableItbCount: assignableRows.length,
assignableIds: assignableRows.map(row => row.id),
- assignableCodes: assignableRows.map(row => row.rfqCode || "")
+ assignableCodes: assignableRows.map(row => row.rfqCode || ""),
+ // 수정 가능한 RFQ 정보
+ updatableRfq: updatableRfq,
+ canUpdate: updatableRfq !== null,
};
- }, [selectedRows]);
+ }, [selectedRows, rfqCategory]);
// 담당자 지정 가능 여부 체크 (상태가 "RFQ 생성" 또는 "구매담당지정"인 ITB가 있는지)
const canAssignPic = selectedRfqData.assignableItbCount > 0;
@@ -69,6 +78,20 @@ export function RfqTableToolbarActions<TData>({
onRefresh?.(); // 테이블 데이터 새로고침
};
+ const handleUpdateGeneralRfqSuccess = () => {
+ // 테이블 선택 초기화
+ table.toggleAllPageRowsSelected(false);
+ // 데이터 새로고침
+ onRefresh?.();
+ };
+
+ const handleUpdateClick = () => {
+ if (selectedRfqData.updatableRfq) {
+ setSelectedRfqForUpdate(selectedRfqData.updatableRfq.id);
+ setShowUpdateDialog(true);
+ }
+ };
+
return (
<>
<div className="flex items-center gap-2">
@@ -131,7 +154,21 @@ export function RfqTableToolbarActions<TData>({
</Button>
{rfqCategory === "general" && (
- <CreateGeneralRfqDialog onSuccess={handleCreateGeneralRfqSuccess} />
+ <>
+ <CreateGeneralRfqDialog onSuccess={handleCreateGeneralRfqSuccess} />
+ {/* 일반견적 수정 버튼 - 선택된 항목이 1개이고 RFQ 생성 상태일 때만 활성화 */}
+ {selectedRfqData.canUpdate && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleUpdateClick}
+ className="flex items-center gap-2"
+ >
+ <Edit className="h-4 w-4" />
+ 일반견적 수정
+ </Button>
+ )}
+ </>
)}
<Button
variant="outline"
@@ -153,6 +190,14 @@ export function RfqTableToolbarActions<TData>({
selectedRfqCodes={selectedRfqData.assignableCodes}
onSuccess={handleAssignSuccess}
/>
+
+ {/* 일반견적 수정 다이얼로그 */}
+ <UpdateGeneralRfqDialog
+ open={showUpdateDialog}
+ onOpenChange={setShowUpdateDialog}
+ rfqId={selectedRfqForUpdate || 0}
+ onSuccess={handleUpdateGeneralRfqSuccess}
+ />
</>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/table/update-general-rfq-dialog.tsx b/lib/rfq-last/table/update-general-rfq-dialog.tsx
new file mode 100644
index 00000000..161a2840
--- /dev/null
+++ b/lib/rfq-last/table/update-general-rfq-dialog.tsx
@@ -0,0 +1,749 @@
+"use client";
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { format } from "date-fns"
+import { CalendarIcon, Plus, Loader2, Trash2, PlusCircle } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+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 { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { updateGeneralRfqAction, getGeneralRfqForUpdate } from "../service"
+import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
+import { MaterialSearchItem } from "@/lib/material/material-group-service" // 단순 타입 임포트 목적
+import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
+import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
+import { ProjectSelector } from "@/components/ProjectSelector"
+import {
+ PurchaseGroupCodeSingleSelector,
+ PurchaseGroupCodeWithUser
+} from "@/components/common/selectors/purchase-group-code"
+
+// 아이템 스키마 (수정용)
+const updateItemSchema = z.object({
+ itemCode: z.string().optional(),
+ itemName: z.string().min(1, "자재명을 입력해주세요"),
+ materialCode: z.string().optional(),
+ materialName: z.string().optional(),
+ quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
+ uom: z.string().min(1, "단위를 입력해주세요"),
+ remark: z.string().optional(),
+})
+
+// 일반견적 수정 폼 스키마
+const updateGeneralRfqSchema = z.object({
+ rfqType: z.string().min(1, "견적 종류를 선택해주세요"),
+ rfqTitle: z.string().min(1, "견적명을 입력해주세요"),
+ dueDate: z.date({
+ required_error: "제출마감일을 선택해주세요",
+ }),
+ picUserId: z.number().min(1, "견적담당자를 선택해주세요"),
+ projectId: z.number().optional(),
+ remark: z.string().optional(),
+ items: z.array(updateItemSchema).min(1, "최소 하나의 자재를 추가해주세요"),
+})
+
+type UpdateGeneralRfqFormValues = z.infer<typeof updateGeneralRfqSchema>
+
+interface UpdateGeneralRfqDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ rfqId: number;
+ onSuccess?: () => void;
+}
+
+export function UpdateGeneralRfqDialog({
+ open,
+ onOpenChange,
+ rfqId,
+ onSuccess
+}: UpdateGeneralRfqDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isLoadingData, setIsLoadingData] = React.useState(false)
+ const [selectedPurchaseGroupCode, setSelectedPurchaseGroupCode] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [selectorOpen, setSelectorOpen] = React.useState(false)
+ const { data: session } = useSession()
+
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null;
+ }, [session]);
+
+ const form = useForm<UpdateGeneralRfqFormValues>({
+ resolver: zodResolver(updateGeneralRfqSchema),
+ defaultValues: {
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: userId || undefined,
+ projectId: undefined,
+ remark: "",
+ items: [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ })
+
+ // 견적 종류 변경
+ const handleRfqTypeChange = (value: string) => {
+ form.setValue("rfqType", value)
+ }
+
+ // 구매그룹코드 선택 핸들러
+ const handlePurchaseGroupCodeSelect = React.useCallback((code: PurchaseGroupCodeWithUser) => {
+ setSelectedPurchaseGroupCode(code)
+
+ // 사용자 정보가 있으면 폼에 설정
+ if (code.user) {
+ form.setValue("picUserId", code.user.id)
+ } else {
+ // 유저 정보가 없는 경우 경고
+ toast.warning(
+ `해당 구매그룹코드(${code.PURCHASE_GROUP_CODE})의 사번 정보의 유저가 없습니다`,
+ {
+ description: `사번: ${code.EMPLOYEE_NUMBER}`,
+ duration: 5000,
+ }
+ )
+ }
+ }, [form])
+
+ // 데이터 로드 함수
+ const loadRfqData = React.useCallback(async () => {
+ if (!rfqId || !open) return
+
+ setIsLoadingData(true)
+ try {
+ const result = await getGeneralRfqForUpdate(rfqId)
+
+ if (result.success && result.data) {
+ const data = result.data
+
+ // 폼 데이터 설정
+ form.reset({
+ rfqType: data.rfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: new Date(data.dueDate),
+ picUserId: data.picUserId,
+ projectId: data.projectId,
+ remark: data.remark || "",
+ items: data.items.length > 0 ? data.items : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+
+ // 구매그룹코드 정보도 초기화 (필요시)
+ // TODO: picUserId로부터 구매그룹코드 정보를 조회하여 설정
+
+ } else {
+ toast.error(result.error || "일반견적 데이터를 불러올 수 없습니다")
+ onOpenChange(false)
+ }
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("일반견적 데이터를 불러오는 중 오류가 발생했습니다")
+ onOpenChange(false)
+ } finally {
+ setIsLoadingData(false)
+ }
+ }, [rfqId, open, form, onOpenChange])
+
+ // 다이얼로그 열림/닫힘 처리
+ React.useEffect(() => {
+ if (open && rfqId) {
+ loadRfqData()
+ } else if (!open) {
+ // 다이얼로그가 닫힐 때 폼 초기화
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: userId || undefined,
+ projectId: undefined,
+ remark: "",
+ items: [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedPurchaseGroupCode(undefined)
+ }
+ }, [open, rfqId, form, userId, loadRfqData])
+
+ const onSubmit = async (data: UpdateGeneralRfqFormValues) => {
+ if (!userId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ if (!rfqId) {
+ toast.error("수정할 일반견적 ID가 없습니다")
+ return
+ }
+
+ setIsLoading(true)
+
+ try {
+ // 서버 액션 호출
+ const result = await updateGeneralRfqAction({
+ id: rfqId,
+ rfqType: data.rfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: data.dueDate,
+ picUserId: data.picUserId,
+ projectId: data.projectId,
+ remark: data.remark || "",
+ items: data.items as Array<{
+ itemCode: string;
+ itemName: string;
+ materialCode?: string;
+ materialName?: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>,
+ updatedBy: userId,
+ })
+
+ if (result.success) {
+ toast.success(result.message)
+
+ // 다이얼로그 닫기
+ onOpenChange(false)
+
+ // 성공 콜백 실행
+ if (onSuccess) {
+ onSuccess()
+ }
+
+ } else {
+ toast.error(result.error || "일반견적 수정에 실패했습니다")
+ }
+
+ } catch (error) {
+ console.error('일반견적 수정 오류:', error)
+ toast.error("일반견적 수정에 실패했습니다", {
+ description: "알 수 없는 오류가 발생했습니다",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 아이템 추가
+ const handleAddItem = () => {
+ append({
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col">
+ {/* 고정된 헤더 */}
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>일반견적 수정</DialogTitle>
+ <DialogDescription>
+ 기존 일반견적을 수정합니다. 필수 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <ScrollArea className="flex-1 px-1">
+ {isLoadingData ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin mr-2" />
+ <span>데이터를 불러오는 중...</span>
+ </div>
+ ) : (
+ <Form {...form}>
+ <form id="updateGeneralRfqForm" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
+
+ {/* 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 견적 종류 */}
+ <div className="space-y-2">
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적 종류 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={handleRfqTypeChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="견적 종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="단가계약">단가계약</SelectItem>
+ <SelectItem value="매각계약">매각계약</SelectItem>
+ <SelectItem value="일반계약">일반계약</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 제출마감일 */}
+ <FormField
+ control={form.control}
+ name="dueDate"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 제출마감일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ className={cn(
+ "w-full pl-3 text-left font-normal",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value ? (
+ format(field.value, "yyyy-MM-dd")
+ ) : (
+ <span>제출마감일을 선택하세요</span>
+ )}
+ <CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-auto p-0" align="start">
+ <Calendar
+ mode="single"
+ selected={field.value}
+ onSelect={field.onChange}
+ disabled={(date) =>
+ date < new Date() || date < new Date("1900-01-01")
+ }
+ initialFocus
+ />
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 견적명 */}
+ <FormField
+ control={form.control}
+ name="rfqTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 2025년 1분기 사무용품 구매 견적"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 견적의 목적이나 내용을 간단명료하게 입력해주세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ <ProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={(project) => field.onChange(project.id)}
+ placeholder="프로젝트 선택 (선택사항)..."
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 구매 담당자 - 구매그룹코드 선택기 */}
+ <FormField
+ control={form.control}
+ name="picUserId"
+ render={() => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적담당자 (구매그룹코드) <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full justify-start h-auto min-h-[36px]"
+ onClick={() => setSelectorOpen(true)}
+ >
+ {selectedPurchaseGroupCode ? (
+ <div className="flex flex-col items-start gap-1 w-full">
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary" className="font-mono text-xs">
+ {selectedPurchaseGroupCode.PURCHASE_GROUP_CODE}
+ </Badge>
+ <span className="text-sm">{selectedPurchaseGroupCode.DISPLAY_NAME}</span>
+ </div>
+ {selectedPurchaseGroupCode.user && (
+ <div className="text-xs text-muted-foreground">
+ 담당자: {selectedPurchaseGroupCode.user.name} ({selectedPurchaseGroupCode.user.email})
+ </div>
+ )}
+ {!selectedPurchaseGroupCode.user && (
+ <div className="text-xs text-orange-600">
+ ⚠️ 연결된 사용자가 없습니다
+ </div>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground text-sm">
+ 구매그룹코드를 선택하세요
+ </span>
+ )}
+ </Button>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 비고사항을 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 아이템 정보 섹션 - 컴팩트한 UI */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">자재 정보</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleAddItem}
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ 자재 추가
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ {fields.map((field, index) => (
+ <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50">
+ <div className="flex items-center justify-between mb-3">
+ <span className="text-sm font-medium text-gray-700">
+ 자재 #{index + 1}
+ </span>
+ {fields.length > 1 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => remove(index)}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+
+ {/* 자재그룹 선택 - 그리드 외부 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재그룹(자재그룹명) <span className="text-red-500">*</span>
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel="자재그룹 선택"
+ selectedMaterial={(() => {
+ const itemCode = form.watch(`items.${index}.itemCode`);
+ const itemName = form.watch(`items.${index}.itemName`);
+ if (itemCode && itemName) {
+ return {
+ materialGroupCode: itemCode,
+ materialGroupDescription: itemName,
+ displayText: `${itemCode} - ${itemName}`
+ } as MaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.itemCode`, material?.materialGroupCode || '');
+ form.setValue(`items.${index}.itemName`, material?.materialGroupDescription || '');
+ }}
+ placeholder="자재그룹을 검색하세요..."
+ title="자재그룹 선택"
+ description="원하는 자재그룹을 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ {/* 자재코드 선택 - 그리드 외부 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재코드(자재명)
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialSelectorDialogSingle
+ triggerLabel="자재코드 선택"
+ selectedMaterial={(() => {
+ const materialCode = form.watch(`items.${index}.materialCode`);
+ const materialName = form.watch(`items.${index}.materialName`);
+ if (materialCode && materialName) {
+ return {
+ materialCode: materialCode,
+ materialName: materialName,
+ displayText: `${materialCode} - ${materialName}`
+ } as SAPMaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.materialCode`, material?.materialCode || '');
+ form.setValue(`items.${index}.materialName`, material?.materialName || '');
+ }}
+ placeholder="자재코드를 검색하세요..."
+ title="자재코드 선택"
+ description="원하는 자재코드를 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ {/* 수량 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 수량 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ placeholder="1"
+ className="h-8 text-sm"
+ {...field}
+ onChange={(e) => field.onChange(Number(e.target.value))}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단위 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.uom`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 단위 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger className="h-8 text-sm">
+ <SelectValue placeholder="단위 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="EA">EA (Each)</SelectItem>
+ <SelectItem value="KG">KG (Kilogram)</SelectItem>
+ <SelectItem value="M">M (Meter)</SelectItem>
+ <SelectItem value="L">L (Liter)</SelectItem>
+ <SelectItem value="PC">PC (Piece)</SelectItem>
+ <SelectItem value="BOX">BOX (Box)</SelectItem>
+ <SelectItem value="SET">SET (Set)</SelectItem>
+ <SelectItem value="LOT">LOT (Lot)</SelectItem>
+ <SelectItem value="PCS">PCS (Pieces)</SelectItem>
+ <SelectItem value="TON">TON (Ton)</SelectItem>
+ <SelectItem value="G">G (Gram)</SelectItem>
+ <SelectItem value="ML">ML (Milliliter)</SelectItem>
+ <SelectItem value="CM">CM (Centimeter)</SelectItem>
+ <SelectItem value="MM">MM (Millimeter)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 비고 - 별도 행에 배치 */}
+ <div className="mt-3">
+ <FormField
+ control={form.control}
+ name={`items.${index}.remark`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">비고</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="자재별 비고사항"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </form>
+ </Form>
+ )}
+ </ScrollArea>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading || isLoadingData}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="updateGeneralRfqForm"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading || isLoadingData}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "수정 중..." : "일반견적 수정"}
+ </Button>
+ </DialogFooter>
+
+ {/* 구매그룹코드 선택 다이얼로그 */}
+ <PurchaseGroupCodeSingleSelector
+ open={selectorOpen}
+ onOpenChange={setSelectorOpen}
+ selectedCode={selectedPurchaseGroupCode}
+ onCodeSelect={handlePurchaseGroupCodeSelect}
+ title="견적 담당자 선택"
+ description="일반견적의 담당자를 구매그룹코드로 선택하세요"
+ showConfirmButtons={false}
+ />
+ </DialogContent>
+ </Dialog>
+ )
+}
diff --git a/lib/site-visit/client-site-visit-wrapper.tsx b/lib/site-visit/client-site-visit-wrapper.tsx
index 6801445d..aa466771 100644
--- a/lib/site-visit/client-site-visit-wrapper.tsx
+++ b/lib/site-visit/client-site-visit-wrapper.tsx
@@ -36,10 +36,23 @@ function getTotalShiAttendees(shiAttendees: Record<string, unknown> | null): num
let total = 0
Object.entries(shiAttendees).forEach(([, value]) => {
- if (value && typeof value === 'object' && 'checked' in value && 'count' in value) {
- const attendee = value as { checked: boolean; count: number }
- if (attendee.checked) {
- total += attendee.count
+ if (value && typeof value === 'object' && 'checked' in value) {
+ const attendeeData = value as {
+ checked: boolean;
+ attendees?: Array<{ name: string; department?: string; email?: string }>;
+ // 기존 구조 호환성
+ count?: number;
+ }
+
+ if (attendeeData.checked) {
+ // 새로운 구조인 경우 (attendees 배열)
+ if (attendeeData.attendees && Array.isArray(attendeeData.attendees)) {
+ total += attendeeData.attendees.length
+ }
+ // 기존 구조인 경우 (count)
+ else if (attendeeData.count !== undefined) {
+ total += attendeeData.count
+ }
}
}
})
diff --git a/lib/site-visit/service.ts b/lib/site-visit/service.ts
index 1dc07c77..d78682b5 100644
--- a/lib/site-visit/service.ts
+++ b/lib/site-visit/service.ts
@@ -1,9 +1,9 @@
"use server"
import db from "@/db/db"
-import { and, eq, isNull, desc, sql} from "drizzle-orm";
+import { and, eq, isNull, desc, sql, ne, or, ilike} from "drizzle-orm";
import { revalidatePath} from "next/cache";
-import { format } from "date-fns"
+import { format, addDays } from "date-fns"
import { vendorInvestigations, vendorPQSubmissions, siteVisitRequests, vendorSiteVisitInfo, siteVisitRequestAttachments } from "@/db/schema/pq"
import { sendEmail } from "../mail/sendEmail";
import { decryptWithServerAction } from '@/components/drm/drmUtils'
@@ -19,9 +19,10 @@ import { users } from "@/db/schema"
// 실사 ID로 모든 siteVisitRequests 조회 (복수 확정정보 지원)
+// 협력업체 제출 정보(vendorSiteVisitInfo) 포함
export async function getAllSiteVisitRequestsForInvestigationAction(investigationId: number) {
try {
- const confirmations = await db
+ const siteVisitRequestsList = await db
.select({
id: siteVisitRequests.id,
status: siteVisitRequests.status,
@@ -36,7 +37,35 @@ export async function getAllSiteVisitRequestsForInvestigationAction(investigatio
.where(eq(siteVisitRequests.investigationId, investigationId))
.orderBy(desc(siteVisitRequests.createdAt))
- return { success: true, confirmations }
+ // 각 siteVisitRequest에 대해 협력업체 제출 정보 조회
+ const requestsWithVendorInfo = await Promise.all(
+ siteVisitRequestsList.map(async (request) => {
+ const vendorInfoResult = await db
+ .select()
+ .from(vendorSiteVisitInfo)
+ .where(eq(vendorSiteVisitInfo.siteVisitRequestId, request.id))
+ .limit(1)
+
+ const vendorInfo = vendorInfoResult.length > 0 ? vendorInfoResult[0] : null
+
+ // 첨부파일 조회 (vendorSiteVisitInfo가 있는 경우)
+ let attachments: any[] = []
+ if (vendorInfo) {
+ attachments = await db
+ .select()
+ .from(siteVisitRequestAttachments)
+ .where(eq(siteVisitRequestAttachments.vendorSiteVisitInfoId, vendorInfo.id))
+ }
+
+ return {
+ ...request,
+ vendorInfo,
+ attachments,
+ }
+ })
+ )
+
+ return { success: true, requests: requestsWithVendorInfo }
} catch (error) {
console.error("실사 확정정보 조회 오류:", error)
return { success: false, error: "실사 확정정보 조회에 실패했습니다." }
@@ -155,7 +184,16 @@ export async function createSiteVisitRequestAction(input: {
inspectionDuration: number;
requestedStartDate: Date;
requestedEndDate: Date;
- shiAttendees: Record<string, boolean>;
+ shiAttendees: {
+ [key: string]: {
+ checked: boolean;
+ attendees: Array<{
+ name: string;
+ department?: string;
+ email?: string;
+ }>;
+ };
+ };
shiAttendeeDetails?: string;
vendorRequests: Record<string, boolean>;
otherVendorRequests?: string;
@@ -176,12 +214,12 @@ export async function createSiteVisitRequestAction(input: {
.where(eq(siteVisitRequests.investigationId, investigationId))
.limit(1);
- if (existingRequest.length > 0) {
- return {
- success: false,
- error: "이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다."
- };
- }
+ // if (existingRequest.length > 0) {
+ // return {
+ // success: false,
+ // error: "이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다."
+ // };
+ // }
// 방문실사 요청 생성
const [siteVisitRequest] = await db
@@ -287,63 +325,118 @@ export async function createSiteVisitRequestAction(input: {
throw new Error('발송자 정보를 찾을 수 없습니다.');
}
- // 마감일 계산 (발송일 + 7일)
- const deadlineDate = format(new Date(), 'yyyy.MM.dd');
+ // 마감일 계산 (발송일 + 7일 또는 실사 예정일 중 먼저 도래하는 날)
+ const deadlineDate = (() => {
+ const deadlineFromToday = addDays(new Date(), 7);
+ if (investigation.forecastedAt) {
+ const forecastedDate = new Date(investigation.forecastedAt);
+ return forecastedDate < deadlineFromToday ? format(forecastedDate, 'yyyy.MM.dd') : format(deadlineFromToday, 'yyyy.MM.dd');
+ }
+ return format(deadlineFromToday, 'yyyy.MM.dd');
+ })();
+
+ // 실사 방법 한글 매핑
+ const investigationMethodMap: Record<string, string> = {
+ 'PURCHASE_SELF_EVAL': '구매자체평가',
+ 'DOCUMENT_EVAL': '서류평가',
+ 'PRODUCT_INSPECTION': '제품검사평가',
+ 'SITE_VISIT_EVAL': '방문실사평가'
+ };
+ const investigationMethodKorean = investigation.investigationMethod
+ ? (investigationMethodMap[investigation.investigationMethod] || investigation.investigationMethod)
+ : null;
// SHI 참석자 정보 파싱 (새로운 구조에 맞게)
const shiAttendees = input.shiAttendees as any;
+ // 실사 주소 및 기간/일정은 QM이 입력한 값 사용
+ const investigationAddress = investigation.investigationAddress || '';
+ const scheduledStartDate = investigation.scheduledStartAt
+ ? format(new Date(investigation.scheduledStartAt), 'yyyy.MM.dd')
+ : format(siteVisitRequest.requestedStartDate!, 'yyyy.MM.dd');
+ const scheduledEndDate = investigation.scheduledEndAt
+ ? format(new Date(investigation.scheduledEndAt), 'yyyy.MM.dd')
+ : format(siteVisitRequest.requestedEndDate!, 'yyyy.MM.dd');
+ const scheduledDuration = investigation.scheduledStartAt && investigation.scheduledEndAt
+ ? Math.ceil((new Date(investigation.scheduledEndAt).getTime() - new Date(investigation.scheduledStartAt).getTime()) / (1000 * 60 * 60 * 24))
+ : siteVisitRequest.inspectionDuration;
+
+ // SHI 참석자 정보 (새로운 구조: attendees 배열)
+ const shiAttendeesList: string[] = [];
+ const attendeeEmails: string[] = [];
+
+ Object.entries(shiAttendees).forEach(([key, value]: [string, any]) => {
+ if (value?.checked && value?.attendees && Array.isArray(value.attendees) && value.attendees.length > 0) {
+ const departmentLabels: Record<string, string> = {
+ technicalSales: "기술영업",
+ design: "설계",
+ procurement: "구매",
+ quality: "품질",
+ production: "생산",
+ commissioning: "시운전",
+ other: "기타"
+ };
+ const departmentName = departmentLabels[key] || key;
+
+ // 참석자 목록 생성
+ const attendeeCount = value.attendees.length;
+ const attendeeDetails = value.attendees
+ .map((attendee: any) => {
+ const parts: string[] = [];
+ if (attendee.name) parts.push(attendee.name);
+ if (attendee.department) parts.push(attendee.department);
+ return parts.join(' / ');
+ })
+ .filter(Boolean)
+ .join(', ');
+
+ const details = attendeeDetails ? ` (${attendeeDetails})` : '';
+ shiAttendeesList.push(`${departmentName} ${attendeeCount}명${details}`);
+
+ // 이메일 수집
+ value.attendees.forEach((attendee: any) => {
+ if (attendee?.email && attendee.email.trim() && attendee.email.includes('@')) {
+ attendeeEmails.push(attendee.email.trim());
+ }
+ });
+ }
+ });
+
+ // 중복 제거 및 유효성 검증
+ const uniqueAttendeeEmails = Array.from(new Set(attendeeEmails.filter(email => email && email.includes('@'))));
+
// 메일 제목
- const subject = `[SHI Audit] 방문실사 시행 안내 및 실사 관련 추가정보 요청 _ ${vendor.vendorName} (${vendor.vendorCode}, 사업자번호: ${vendor.taxId})`;
+ const subject = `[SHI Audit] 방문실사 시행 안내 및 실사 관련 추가정보 요청 _ ${vendor.vendorName}`;
// 메일 컨텍스트
const context = {
// 기본 정보
vendorName: vendor.vendorName,
- vendorContactName: vendor.vendorName || '',
+ vendorEmail: vendor.email || '',
requesterName: sender.name,
requesterTitle: 'Procurement Manager',
requesterEmail: sender.email,
// 실사 정보
- investigationMethod: investigation.investigationMethod,
- // investigationMethodDescription: investigation.investigationMethodDescription,
- requestedStartDate: format(siteVisitRequest.requestedStartDate!, 'yyyy.MM.dd'),
- requestedEndDate: format(siteVisitRequest.requestedEndDate!, 'yyyy.MM.dd'),
- inspectionDuration: siteVisitRequest.inspectionDuration,
+ investigationMethod: investigationMethodKorean,
+ investigationAddress: investigationAddress,
+ requestedStartDate: scheduledStartDate,
+ requestedEndDate: scheduledEndDate,
+ inspectionDuration: scheduledDuration,
// 마감일
deadlineDate,
// SHI 참석자 정보 (새로운 구조)
- shiAttendees: Object.entries(shiAttendees)
- .filter(([, value]) => value.checked)
- .map(([key, value]) => {
- const departmentLabels: Record<string, string> = {
- technicalSales: "기술영업",
- design: "설계",
- procurement: "구매",
- quality: "품질",
- production: "생산",
- commissioning: "시운전",
- other: "기타"
- };
- const departmentName = departmentLabels[key] || key;
- const details = value.details ? ` (${value.details})` : '';
- return `${departmentName} ${value.count}명${details}`;
- }),
+ shiAttendees: shiAttendeesList,
shiAttendeeDetails: input.shiAttendeeDetails || null,
// 협력업체 요청 정보 (default 값으로 고정)
vendorRequests: [
- ' 실사공장명',
- ' 실사공장 주소',
- ' 실사공장 가는 방법',
- ' 실사공장 Contact Point',
- ' 실사공장 연락처',
- ' 실사공장 이메일',
- ' 실사 참석 예정인력',
- ' 공장 출입절차 및 준비물'
+ '실사 공장 정보(공장명, 주소, 접근 방법 등)',
+ '실사 일정 확인',
+ '협력업체 실사 참석자 정보',
+ '사전 조치 필요 사항(출입증 등)'
],
otherVendorRequests: input.otherVendorRequests,
@@ -358,13 +451,24 @@ export async function createSiteVisitRequestAction(input: {
};
// 메일 발송 (벤더 이메일로 직접 발송)
+ // cc에는 요청자 및 SHI 참석자 이메일 모두 포함
+ const ccEmails: string[] = [];
+ if (sender.email) {
+ ccEmails.push(sender.email);
+ }
+ // 참석자 이메일 추가 (요청자 이메일과 중복 제거)
+ uniqueAttendeeEmails.forEach(email => {
+ if (email && email !== sender.email && !ccEmails.includes(email)) {
+ ccEmails.push(email);
+ }
+ });
+
await sendEmail({
to: vendor.email || '',
- cc: sender.email,
+ cc: ccEmails.length > 0 ? ccEmails : undefined,
subject,
template: 'site-visit-request' as string,
context,
- // cc: vendor.email !== sender.email ? sender.email : undefined
});
console.log('방문실사 요청 메일 발송 완료:', {
@@ -770,4 +874,47 @@ export async function getSiteVisitRequestAction(investigationId: number) {
error: "협력업체 방문실사 정보 조회 중 오류가 발생했습니다."
};
}
+ }
+
+ // domain이 'partners'가 아닌 사용자 목록 가져오기
+ export async function getUsersForSiteVisitAction(searchQuery?: string) {
+ try {
+ let whereCondition = ne(users.domain, "partners");
+
+ // 검색 쿼리가 있으면 이름 또는 이메일로 필터링
+ if (searchQuery && searchQuery.trim()) {
+ const searchPattern = `%${searchQuery.trim()}%`;
+ whereCondition = and(
+ ne(users.domain, "partners"),
+ or(
+ ilike(users.name, searchPattern),
+ ilike(users.email, searchPattern)
+ )
+ ) as any;
+ }
+
+ const userList = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ deptName: users.deptName,
+ })
+ .from(users)
+ .where(whereCondition)
+ .orderBy(users.name)
+ .limit(100); // 최대 100명까지
+
+ return {
+ success: true,
+ data: userList,
+ };
+ } catch (error) {
+ console.error("사용자 목록 조회 오류:", error);
+ return {
+ success: false,
+ error: "사용자 목록 조회 중 오류가 발생했습니다.",
+ data: [],
+ };
+ }
} \ No newline at end of file
diff --git a/lib/site-visit/shi-attendees-dialog.tsx b/lib/site-visit/shi-attendees-dialog.tsx
index 3d7d94a1..b2c915f1 100644
--- a/lib/site-visit/shi-attendees-dialog.tsx
+++ b/lib/site-visit/shi-attendees-dialog.tsx
@@ -94,6 +94,26 @@ export function ShiAttendeesDialog({
onOpenChange,
selectedRequest,
}: ShiAttendeesDialogProps) {
+ // 기존 구조 감지 및 alert 표시
+ // React.useEffect(() => {
+ // if (isOpen && selectedRequest?.shiAttendees) {
+ // const hasOldStructure = Object.values(selectedRequest.shiAttendees as Record<string, unknown>).some(
+ // (value) => {
+ // if (value && typeof value === 'object' && 'checked' in value && value.checked) {
+ // const attendeeData = value as any;
+ // // 기존 구조 확인: count가 있고 attendees가 없는 경우
+ // return attendeeData.count !== undefined && (!attendeeData.attendees || !Array.isArray(attendeeData.attendees));
+ // }
+ // return false;
+ // }
+ // );
+
+ // if (hasOldStructure) {
+ // alert('이 데이터는 이전 히스토리로, 참석자 정보가 부정확할 수 있습니다.');
+ // }
+ // }
+ // }, [isOpen, selectedRequest]);
+
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
@@ -108,7 +128,15 @@ export function ShiAttendeesDialog({
<div className="space-y-4">
{Object.entries(selectedRequest.shiAttendees as Record<string, unknown>).map(([key, value]) => {
if (value && typeof value === 'object' && 'checked' in value && value.checked) {
- const attendee = value as { checked: boolean; count: number; details?: string }
+ // 새로운 구조 확인: { checked, attendees: [{ name, department, email }] }
+ const attendeeData = value as {
+ checked: boolean;
+ attendees?: Array<{ name: string; department?: string; email?: string }>;
+ // 호환성을 위한 기존 구조도 확인
+ count?: number;
+ details?: string;
+ }
+
const departmentLabels: Record<string, string> = {
technicalSales: "기술영업",
design: "설계",
@@ -119,19 +147,46 @@ export function ShiAttendeesDialog({
other: "기타"
}
- return (
- <div key={key} className="border rounded-lg p-4">
- <div className="flex items-center justify-between mb-2">
- <h4 className="font-semibold">{departmentLabels[key] || key}</h4>
- <Badge variant="outline">{attendee.count}명</Badge>
+ // 새로운 구조인 경우 (attendees 배열)
+ if (attendeeData.attendees && Array.isArray(attendeeData.attendees) && attendeeData.attendees.length > 0) {
+ return (
+ <div key={key} className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-3">
+ <h4 className="font-semibold">{departmentLabels[key] || key}</h4>
+ <Badge variant="outline">{attendeeData.attendees.length}명</Badge>
+ </div>
+ <div className="space-y-2">
+ {attendeeData.attendees.map((attendee, index) => (
+ <div key={index} className="text-sm bg-muted/50 p-2 rounded-md">
+ <div className="font-medium">{attendee.name}</div>
+ {attendee.department && (
+ <div className="text-muted-foreground">부서: {attendee.department}</div>
+ )}
+ {attendee.email && (
+ <div className="text-muted-foreground">이메일: {attendee.email}</div>
+ )}
+ </div>
+ ))}
+ </div>
</div>
- {attendee.details && (
- <div className="text-sm text-muted-foreground">
- <span className="font-medium">참석자 정보:</span> {attendee.details}
+ )
+ }
+ // 기존 구조인 경우 (호환성 유지)
+ else if (attendeeData.count !== undefined) {
+ return (
+ <div key={key} className="border rounded-lg p-4">
+ <div className="flex items-center justify-between mb-2">
+ <h4 className="font-semibold">{departmentLabels[key] || key}</h4>
+ <Badge variant="outline">{attendeeData.count}명</Badge>
</div>
- )}
- </div>
- )
+ {attendeeData.details && (
+ <div className="text-sm text-muted-foreground">
+ <span className="font-medium">참석자 정보:</span> {attendeeData.details}
+ </div>
+ )}
+ </div>
+ )
+ }
}
return null
})}
diff --git a/lib/site-visit/vendor-info-view-dialog.tsx b/lib/site-visit/vendor-info-view-dialog.tsx
index 48aefeb0..fb2b0dfe 100644
--- a/lib/site-visit/vendor-info-view-dialog.tsx
+++ b/lib/site-visit/vendor-info-view-dialog.tsx
@@ -1,9 +1,7 @@
"use client"
import * as React from "react"
-import { format } from "date-fns"
-import { ko } from "date-fns/locale"
-import { Building2, User, Phone, Mail, FileText, Calendar } from "lucide-react"
+import { Building2, User, Phone, Mail, FileText, Calendar, ChevronRight } from "lucide-react"
import { formatDate } from "../utils"
import {
@@ -50,6 +48,19 @@ interface Attachment {
updatedAt: Date
}
+interface SiteVisitRequest {
+ id: number
+ status: string
+ inspectionDuration: number
+ requestedStartDate: Date
+ requestedEndDate: Date
+ additionalRequests: string | null
+ createdAt: Date
+ updatedAt: Date
+ vendorInfo: VendorInfo | null
+ attachments: Attachment[]
+}
+
interface VendorInfoViewDialogProps {
isOpen: boolean
onClose: () => void
@@ -58,6 +69,223 @@ interface VendorInfoViewDialogProps {
isReinspection?: boolean // 재실사 모드 플래그
}
+// 상세 정보를 표시하는 내부 컴포넌트
+function VendorDetailView({
+ vendorInfo,
+ attachments,
+ siteVisitRequest
+}: {
+ vendorInfo: VendorInfo | null
+ attachments: Attachment[]
+ siteVisitRequest?: SiteVisitRequest
+}) {
+ if (!vendorInfo) {
+ return (
+ <div className="text-center py-8">
+ <div className="text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>협력업체가 아직 정보를 입력하지 않았습니다.</p>
+ </div>
+ </div>
+ )
+ }
+
+ return (
+ <div className="space-y-6">
+ {/* 협력업체 공장 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Building2 className="h-5 w-5" />
+ 협력업체 공장 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div className="space-y-4">
+ <div>
+ <h4 className="font-semibold mb-2">공장 기본 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div><span className="font-medium">공장명:</span> {vendorInfo.factoryName}</div>
+ <div><span className="font-medium">공장위치:</span> {vendorInfo.factoryLocation}</div>
+ <div><span className="font-medium">공장주소:</span> {vendorInfo.factoryAddress}</div>
+ </div>
+ </div>
+
+ <div>
+ <h4 className="font-semibold mb-2">공장 담당자 정보</h4>
+ <div className="space-y-2 text-sm">
+ <div className="flex items-center gap-2">
+ <User className="h-4 w-4" />
+ <span>{vendorInfo.factoryPicName}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Phone className="h-4 w-4" />
+ <span>{vendorInfo.factoryPicPhone}</span>
+ </div>
+ <div className="flex items-center gap-2">
+ <Mail className="h-4 w-4" />
+ <span>{vendorInfo.factoryPicEmail}</span>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <div className="space-y-4">
+ {vendorInfo.factoryDirections && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 가는 법</h4>
+ <div className="bg-muted p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{vendorInfo.factoryDirections}</p>
+ </div>
+ </div>
+ )}
+
+ {vendorInfo.accessProcedure && (
+ <div>
+ <h4 className="font-semibold mb-2">공장 출입절차</h4>
+ <div className="bg-muted p-3 rounded-md">
+ <p className="text-sm whitespace-pre-wrap">{vendorInfo.accessProcedure}</p>
+ </div>
+ </div>
+ )}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 첨부파일 */}
+ {attachments.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 협력업체 첨부파일 ({attachments.length}개)
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-2">
+ {attachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 border rounded-md">
+ <div className="flex items-center space-x-2 flex-1 min-w-0">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <span className="text-sm truncate">{attachment.originalFileName}</span>
+ <span className="text-xs text-muted-foreground">
+ ({Math.round((attachment.fileSize || 0) / 1024)}KB)
+ </span>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={async () => {
+ try {
+ const { downloadFile } = await import('@/lib/file-download')
+ await downloadFile(attachment.filePath, attachment.originalFileName || '', {
+ showToast: true,
+ onError: (error) => {
+ console.error('다운로드 오류:', error)
+ toast.error(error)
+ },
+ onSuccess: (fileName, fileSize) => {
+ console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
+ }
+ })
+ } catch (error) {
+ console.error('다운로드 오류:', error)
+ toast.error('파일 다운로드 중 오류가 발생했습니다.')
+ }
+ }}
+ >
+ 다운로드
+ </Button>
+ </div>
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 실사 정보 */}
+ {siteVisitRequest && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 실사 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">실사 기간:</span> {siteVisitRequest.inspectionDuration}일
+ </div>
+ <div>
+ <span className="font-medium">요청 시작일:</span>
+ {siteVisitRequest.requestedStartDate ? formatDate(siteVisitRequest.requestedStartDate, "kr") : "미정"}
+ </div>
+ <div>
+ <span className="font-medium">요청 종료일:</span>
+ {siteVisitRequest.requestedEndDate ? formatDate(siteVisitRequest.requestedEndDate, "kr") : "미정"}
+ </div>
+ <div>
+ <span className="font-medium">상태:</span>
+ <Badge variant={siteVisitRequest.status === "VENDOR_SUBMITTED" ? "default" : "secondary"} className="ml-2">
+ {siteVisitRequest.status === "VENDOR_SUBMITTED" ? "제출완료" : siteVisitRequest.status === "SENT" ? "발송완료" : "요청됨"}
+ </Badge>
+ </div>
+ {siteVisitRequest.additionalRequests && (
+ <div className="col-span-2">
+ <span className="font-medium">추가 요청사항:</span>
+ <div className="bg-muted p-2 rounded mt-1 text-sm whitespace-pre-wrap">{siteVisitRequest.additionalRequests}</div>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 기타 정보 */}
+ {vendorInfo.otherInfo && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <FileText className="h-5 w-5" />
+ 기타 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <p className="text-sm whitespace-pre-wrap">{vendorInfo.otherInfo}</p>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 제출 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="flex items-center gap-2">
+ <Calendar className="h-5 w-5" />
+ 제출 정보
+ </CardTitle>
+ </CardHeader>
+ <CardContent>
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <div className="space-y-2 text-sm">
+ <div>
+ <span className="font-medium">제출일:</span>{" "}
+ {vendorInfo.submittedAt ? formatDate(vendorInfo.submittedAt, "kr") : "-"}
+ </div>
+ <div><span className="font-medium">첨부파일:</span> {vendorInfo.hasAttachments ? "있음" : "없음"}</div>
+ </div>
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ )
+}
+
export function VendorInfoViewDialog({
isOpen,
onClose,
@@ -66,13 +294,22 @@ export function VendorInfoViewDialog({
}: VendorInfoViewDialogProps) {
const [data, setData] = React.useState<VendorInfo | null>(null)
const [attachments, setAttachments] = React.useState<Attachment[]>([])
- const [allConfirmations, setAllConfirmations] = React.useState<any[]>([]) // 여러 확정정보
+ const [siteVisitRequests, setSiteVisitRequests] = React.useState<SiteVisitRequest[]>([])
const [isLoading, setIsLoading] = React.useState(false)
+ const [selectedRequest, setSelectedRequest] = React.useState<SiteVisitRequest | null>(null)
+ const [detailDialogOpen, setDetailDialogOpen] = React.useState(false)
// 데이터 로드
React.useEffect(() => {
if (isOpen && (siteVisitRequestId || investigationId)) {
loadData()
+ } else {
+ // Dialog가 닫힐 때 상태 초기화
+ setData(null)
+ setAttachments([])
+ setSiteVisitRequests([])
+ setSelectedRequest(null)
+ setDetailDialogOpen(false)
}
}, [isOpen, siteVisitRequestId, investigationId])
@@ -81,7 +318,7 @@ export function VendorInfoViewDialog({
setIsLoading(true)
try {
- // 단일 확정정보 조회 (기존)
+ // 단일 확정정보 조회 (기존 방식 - 하위 호환성 유지)
if (siteVisitRequestId) {
const { getVendorSiteVisitInfoAction } = await import("./service")
const result = await getVendorSiteVisitInfoAction(siteVisitRequestId)
@@ -90,16 +327,20 @@ export function VendorInfoViewDialog({
setData(result.data.vendorInfo)
setAttachments(result.data.attachments || [])
} else {
- toast.error("협력업체 정보를 불러올 수 없습니다.")
+ setData(null)
+ setAttachments([])
}
}
- // 여러 확정정보 조회 (신규 - 실사 ID로 모든 siteVisitRequests 조회)
+ // 여러 확정정보 조회 (investigationId 기준)
if (investigationId) {
const { getAllSiteVisitRequestsForInvestigationAction } = await import("./service")
const result = await getAllSiteVisitRequestsForInvestigationAction(investigationId)
if (result.success) {
- setAllConfirmations(result.confirmations || [])
+ setSiteVisitRequests(result.requests || [])
+ } else {
+ setSiteVisitRequests([])
+ toast.error(result.error || "방문실사 정보를 불러올 수 없습니다.")
}
}
} catch (error) {
@@ -110,7 +351,124 @@ export function VendorInfoViewDialog({
}
}
+ const handleListItemClick = (request: SiteVisitRequest) => {
+ setSelectedRequest(request)
+ setDetailDialogOpen(true)
+ }
+
+ const handleCloseDetail = () => {
+ setDetailDialogOpen(false)
+ setSelectedRequest(null)
+ }
+
+ // investigationId가 있는 경우: 리스트 형태 표시
+ if (investigationId) {
+ return (
+ <>
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>협력업체 방문실사 정보</DialogTitle>
+ <DialogDescription>
+ 협력업체가 입력한 방문실사 관련 정보를 확인할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-4"></div>
+ <p className="text-muted-foreground">협력업체 정보를 불러오는 중...</p>
+ </div>
+ </div>
+ ) : siteVisitRequests.length > 0 ? (
+ <div className="space-y-3">
+ {siteVisitRequests.map((request, index) => (
+ <Card
+ key={request.id}
+ className="cursor-pointer hover:bg-muted/50 transition-colors"
+ onClick={() => handleListItemClick(request)}
+ >
+ <CardContent className="p-4">
+ <div className="flex items-center justify-between">
+ <div className="flex-1 min-w-0">
+ <div className="flex items-center gap-3 mb-2">
+ <h4 className="font-semibold text-base">
+ 방문실사 정보 #{index + 1}
+ </h4>
+ <Badge
+ variant={
+ request.vendorInfo
+ ? (request.status === "VENDOR_SUBMITTED" ? "default" : "secondary")
+ : "outline"
+ }
+ >
+ {request.vendorInfo
+ ? (request.status === "VENDOR_SUBMITTED" ? "제출완료" : "발송완료")
+ : "미제출"
+ }
+ </Badge>
+ </div>
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm text-muted-foreground">
+ <div>
+ <span className="font-medium">공장명:</span>{" "}
+ {request.vendorInfo?.factoryName || "미입력"}
+ </div>
+ <div>
+ <span className="font-medium">제출일:</span>{" "}
+ {request.vendorInfo?.submittedAt
+ ? formatDate(request.vendorInfo.submittedAt, "kr")
+ : "-"
+ }
+ </div>
+ <div>
+ <span className="font-medium">실사기간:</span> {request.inspectionDuration}일
+ </div>
+ <div>
+ <span className="font-medium">생성일:</span> {formatDate(request.createdAt, "kr")}
+ </div>
+ </div>
+ </div>
+ <ChevronRight className="h-5 w-5 text-muted-foreground ml-4 flex-shrink-0" />
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-8">
+ <div className="text-muted-foreground">
+ <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
+ <p>협력업체 방문실사 정보가 없습니다.</p>
+ </div>
+ </div>
+ )}
+ </DialogContent>
+ </Dialog>
+
+ {/* 상세 정보 Dialog */}
+ <Dialog open={detailDialogOpen} onOpenChange={(open) => !open && handleCloseDetail()}>
+ <DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>협력업체 방문실사 상세 정보</DialogTitle>
+ <DialogDescription>
+ 협력업체가 입력한 방문실사 관련 상세 정보를 확인할 수 있습니다.
+ </DialogDescription>
+ </DialogHeader>
+ {selectedRequest && (
+ <VendorDetailView
+ vendorInfo={selectedRequest.vendorInfo}
+ attachments={selectedRequest.attachments || []}
+ siteVisitRequest={selectedRequest}
+ />
+ )}
+ </DialogContent>
+ </Dialog>
+ </>
+ )
+ }
+ // siteVisitRequestId가 있는 경우: 기존 방식 (단일 상세 정보 표시)
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
@@ -128,214 +486,10 @@ export function VendorInfoViewDialog({
<p className="text-muted-foreground">협력업체 정보를 불러오는 중...</p>
</div>
</div>
- ) : (data || allConfirmations.length > 0) ? (
- <div className="space-y-6">
- {/* 협력업체 정보 - 단일 확정정보 조회 시에만 표시 */}
- {data && (
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Building2 className="h-5 w-5" />
- 협력업체 공장 정보
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
- <div className="space-y-4">
- <div>
- <h4 className="font-semibold mb-2">공장 기본 정보</h4>
- <div className="space-y-2 text-sm">
- <div><span className="font-medium">공장명:</span> {data.factoryName}</div>
- <div><span className="font-medium">공장위치:</span> {data.factoryLocation}</div>
- <div><span className="font-medium">공장주소:</span> {data.factoryAddress}</div>
- </div>
- </div>
-
- <div>
- <h4 className="font-semibold mb-2">공장 담당자 정보</h4>
- <div className="space-y-2 text-sm">
- <div className="flex items-center gap-2">
- <User className="h-4 w-4" />
- <span>{data.factoryPicName}</span>
- </div>
- <div className="flex items-center gap-2">
- <Phone className="h-4 w-4" />
- <span>{data.factoryPicPhone}</span>
- </div>
- <div className="flex items-center gap-2">
- <Mail className="h-4 w-4" />
- <span>{data.factoryPicEmail}</span>
- </div>
- </div>
- </div>
- </div>
-
- <div className="space-y-4">
- {data.factoryDirections && (
- <div>
- <h4 className="font-semibold mb-2">공장 가는 법</h4>
- <div className="bg-muted p-3 rounded-md">
- <p className="text-sm whitespace-pre-wrap">{data.factoryDirections}</p>
- </div>
- </div>
- )}
-
- {data.accessProcedure && (
- <div>
- <h4 className="font-semibold mb-2">공장 출입절차</h4>
- <div className="bg-muted p-3 rounded-md">
- <p className="text-sm whitespace-pre-wrap">{data.accessProcedure}</p>
- </div>
- </div>
- )}
- </div>
- </div>
- </CardContent>
- </Card>
- )}
-
- {/* 첨부파일 */}
- {attachments.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 협력업체 첨부파일 ({attachments.length}개)
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-2">
- {attachments.map((attachment) => (
- <div key={attachment.id} className="flex items-center justify-between p-2 border rounded-md">
- <div className="flex items-center space-x-2 flex-1 min-w-0">
- <FileText className="h-4 w-4 text-muted-foreground" />
- <span className="text-sm truncate">{attachment.originalFileName}</span>
- <span className="text-xs text-muted-foreground">
- ({Math.round((attachment.fileSize || 0) / 1024)}KB)
- </span>
- </div>
- <Button
- type="button"
- variant="outline"
- size="sm"
- onClick={async () => {
- try {
- const { downloadFile } = await import('@/lib/file-download')
- await downloadFile(attachment.filePath, attachment.originalFileName || '', {
- showToast: true,
- onError: (error) => {
- console.error('다운로드 오류:', error)
- toast.error(error)
- },
- onSuccess: (fileName, fileSize) => {
- console.log(`다운로드 성공: ${fileName} (${fileSize} bytes)`)
- }
- })
- } catch (error) {
- console.error('다운로드 오류:', error)
- toast.error('파일 다운로드 중 오류가 발생했습니다.')
- }
- }}
- >
- 다운로드
- </Button>
- </div>
- ))}
- </div>
- </CardContent>
- </Card>
- )}
-
- {/* 실사 실시 확정정보 (복수 지원) */}
- {allConfirmations.length > 0 && (
- <div className="space-y-4">
- <h3 className="text-lg font-semibold">실사 실시 확정정보</h3>
- {allConfirmations.map((confirmation, index) => (
- <Card key={confirmation.id}>
- <CardHeader>
- <CardTitle className="flex items-center justify-between">
- <span className="flex items-center gap-2">
- <Calendar className="h-5 w-5" />
- 실사 확정정보 #{index + 1}
- </span>
- <Badge variant={confirmation.status === "COMPLETED" ? "default" : "secondary"}>
- {confirmation.status === "COMPLETED" ? "완료" : "진행중"}
- </Badge>
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 gap-4 text-sm">
- <div>
- <span className="font-medium">실사 기간:</span> {confirmation.inspectionDuration}일
- </div>
- <div>
- <span className="font-medium">요청 시작일:</span>
- {confirmation.requestedStartDate ? formatDate(confirmation.requestedStartDate, "kr") : "미정"}
- </div>
- <div>
- <span className="font-medium">요청 종료일:</span>
- {confirmation.requestedEndDate ? formatDate(confirmation.requestedEndDate, "kr") : "미정"}
- </div>
- <div>
- <span className="font-medium">생성일:</span> {formatDate(confirmation.createdAt, "kr")}
- </div>
- {confirmation.additionalRequests && (
- <div className="col-span-2">
- <span className="font-medium">추가 요청사항:</span>
- <div className="bg-muted p-2 rounded mt-1">{confirmation.additionalRequests}</div>
- </div>
- )}
- </div>
- </CardContent>
- </Card>
- ))}
- </div>
- )}
-
- {/* 기타 정보 */}
- {data?.otherInfo && (
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <FileText className="h-5 w-5" />
- 기타 정보
- </CardTitle>
- </CardHeader>
- <CardContent>
- <p className="text-sm whitespace-pre-wrap">{data?.otherInfo}</p>
- </CardContent>
- </Card>
- )}
-
- {/* 제출 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="flex items-center gap-2">
- <Calendar className="h-5 w-5" />
- 제출 정보
- </CardTitle>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-2 gap-4">
- <div>
- <div className="space-y-2 text-sm">
- <div><span className="font-medium">제출일:</span> {formatDate(data?.submittedAt, "kr")}</div>
- <div><span className="font-medium">첨부파일:</span> {data?.hasAttachments ? "있음" : "없음"}</div>
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
) : (
- <div className="text-center py-8">
- <div className="text-muted-foreground">
- <FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
- <p>협력업체가 아직 정보를 입력하지 않았습니다.</p>
- </div>
- </div>
+ <VendorDetailView vendorInfo={data} attachments={attachments} />
)}
</DialogContent>
</Dialog>
)
-} \ No newline at end of file
+} \ No newline at end of file
diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts
index 3ccbe880..c365a7ad 100644
--- a/lib/vendor-investigation/service.ts
+++ b/lib/vendor-investigation/service.ts
@@ -1,6 +1,6 @@
"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
-import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors, siteVisitRequests, vendorPQSubmissions } from "@/db/schema/"
+import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors, siteVisitRequests, vendorPQSubmissions, users } from "@/db/schema/"
import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema, updateVendorInvestigationProgressSchema, updateVendorInvestigationResultSchema } from "./validations"
import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm";
import { revalidateTag, unstable_noStore, revalidatePath } from "next/cache";
@@ -17,6 +17,7 @@ import { cache } from "react"
import { deleteFile } from "../file-stroage";
import { saveDRMFile } from "../file-stroage";
import { decryptWithServerAction } from "@/components/drm/drmUtils";
+import { format, addDays } from "date-fns";
export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) {
return unstable_cache(
@@ -1057,27 +1058,102 @@ export async function requestSupplementDocumentAction({
})
.where(eq(vendorInvestigations.id, investigationId));
- // 2. 서류제출 요청을 위한 방문실사 요청 생성 (서류제출용)
- const [newSiteVisitRequest] = await db
- .insert(siteVisitRequests)
- .values({
- investigationId: investigationId,
- inspectionDuration: 0, // 서류제출은 방문 시간 0
- shiAttendees: {}, // 서류제출은 참석자 없음
- vendorRequests: {
- requiredDocuments: documentRequests.requiredDocuments,
- documentSubmissionOnly: true, // 서류제출 전용 플래그
- },
- additionalRequests: documentRequests.additionalRequests,
- status: "REQUESTED",
- })
- .returning();
+ // 2. 실사, 협력업체, 발송자 정보 조회
+ const investigationResult = await db
+ .select()
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.id, investigationId))
+ .limit(1);
+
+ const investigation = investigationResult[0];
+ if (!investigation) {
+ throw new Error('실사 정보를 찾을 수 없습니다.');
+ }
+
+ const vendorResult = await db
+ .select()
+ .from(vendors)
+ .where(eq(vendors.id, investigation.vendorId))
+ .limit(1);
+
+ const vendor = vendorResult[0];
+ if (!vendor) {
+ throw new Error('협력업체 정보를 찾을 수 없습니다.');
+ }
+
+ const senderResult = await db
+ .select()
+ .from(users)
+ .where(eq(users.id, investigation.requesterId!))
+ .limit(1);
+
+ const sender = senderResult[0];
+ if (!sender) {
+ throw new Error('발송자 정보를 찾을 수 없습니다.');
+ }
+
+ // 마감일 계산 (발송일 + 7일 또는 실사 예정일 중 먼저 도래하는 날)
+ const deadlineDate = (() => {
+ const deadlineFromToday = addDays(new Date(), 7);
+ if (investigation.forecastedAt) {
+ const forecastedDate = new Date(investigation.forecastedAt);
+ return forecastedDate < deadlineFromToday ? forecastedDate : deadlineFromToday;
+ }
+ return deadlineFromToday;
+ })();
+
+ // 메일 제목
+ const subject = `[SHI Audit] 보완 서류제출 요청 _ ${vendor.vendorName}`;
+
+ // 메일 컨텍스트
+ const context = {
+ // 기본 정보
+ vendorName: vendor.vendorName,
+ vendorEmail: vendor.email || '',
+ requesterName: sender.name,
+ requesterTitle: 'Procurement Manager',
+ requesterEmail: sender.email,
+
+ // 보완 요청 서류
+ requiredDocuments: documentRequests.requiredDocuments || [],
+
+ // 추가 요청사항
+ additionalRequests: documentRequests.additionalRequests || null,
+
+ // 마감일
+ deadlineDate: format(deadlineDate, 'yyyy.MM.dd'),
+
+ // 포털 URL
+ portalUrl: `${process.env.NEXT_PUBLIC_BASE_URL}/ko/partners/site-visit`,
+
+ // 현재 연도
+ currentYear: new Date().getFullYear()
+ };
+
+ // 메일 발송 (벤더 이메일로 직접 발송)
+ try {
+ await sendEmail({
+ to: vendor.email || '',
+ cc: sender.email,
+ subject,
+ template: 'supplement-document-request' as string,
+ context,
+ });
+
+ console.log('보완 서류제출 요청 메일 발송 완료:', {
+ to: vendor.email,
+ subject,
+ vendorName: vendor.vendorName
+ });
+ } catch (emailError) {
+ console.error('보완 서류제출 요청 메일 발송 실패:', emailError);
+ }
// 3. 캐시 무효화
revalidateTag("vendor-investigations");
revalidateTag("site-visit-requests");
- return { success: true, siteVisitRequestId: newSiteVisitRequest.id };
+ return { success: true };
} catch (error) {
console.error("보완-서류제출 요청 실패:", error);
return {
@@ -1325,9 +1401,91 @@ export async function submitSupplementDocumentResponseAction({
return { success: true };
} catch (error) {
console.error("보완 서류제출 응답 처리 실패:", error);
- return {
- success: false,
- error: error instanceof Error ? error.message : "알 수 없는 오류"
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ };
+ }
+}
+
+// QM 담당자 변경 서버 액션
+export async function updateQMManagerAction({
+ investigationId,
+ qmManagerId,
+}: {
+ investigationId: number;
+ qmManagerId: number;
+}) {
+ try {
+ // 1. 실사 정보 조회 (상태 확인)
+ const investigation = await db
+ .select({
+ investigationStatus: vendorInvestigations.investigationStatus,
+ currentQmManagerId: vendorInvestigations.qmManagerId,
+ })
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.id, investigationId))
+ .limit(1);
+
+ if (!investigation || investigation.length === 0) {
+ return {
+ success: false,
+ error: "실사를 찾을 수 없습니다."
+ };
+ }
+
+ const currentInvestigation = investigation[0];
+
+ // 2. 상태 검증 (계획 상태만 변경 가능)
+ if (currentInvestigation.investigationStatus !== "PLANNED") {
+ return {
+ success: false,
+ error: "계획 상태인 실사만 QM 담당자를 변경할 수 있습니다."
+ };
+ }
+
+ // 3. QM 담당자 정보 조회
+ const qmManager = await db
+ .select({
+ id: users.id,
+ name: users.name,
+ email: users.email,
+ })
+ .from(users)
+ .where(eq(users.id, qmManagerId))
+ .limit(1);
+
+ if (!qmManager || qmManager.length === 0) {
+ return {
+ success: false,
+ error: "존재하지 않는 QM 담당자입니다."
+ };
+ }
+
+ const qmUser = qmManager[0];
+
+ // 4. QM 담당자 업데이트
+ await db
+ .update(vendorInvestigations)
+ .set({
+ qmManagerId: qmManagerId,
+ updatedAt: new Date(),
+ })
+ .where(eq(vendorInvestigations.id, investigationId));
+
+ // 5. 캐시 무효화
+ revalidateTag("vendor-investigations");
+
+ return {
+ success: true,
+ message: "QM 담당자가 성공적으로 변경되었습니다."
+ };
+
+ } catch (error) {
+ console.error("QM 담당자 변경 오류:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "QM 담당자 변경 중 오류가 발생했습니다."
};
}
}
diff --git a/lib/vendor-investigation/table/change-qm-manager-dialog.tsx b/lib/vendor-investigation/table/change-qm-manager-dialog.tsx
new file mode 100644
index 00000000..11f59937
--- /dev/null
+++ b/lib/vendor-investigation/table/change-qm-manager-dialog.tsx
@@ -0,0 +1,183 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { UserCombobox } from "../../pq/pq-review-table-new/user-combobox"
+import { getQMManagers } from "@/lib/pq/service"
+import { updateQMManagerAction } from "../service"
+import { toast } from "sonner"
+
+// QM 사용자 타입
+interface QMUser {
+ id: number
+ name: string
+ email: string
+ department?: string
+}
+
+const changeQMSchema = z.object({
+ qmManagerId: z.number({
+ required_error: "QM 담당자를 선택해주세요.",
+ }),
+})
+
+type ChangeQMFormValues = z.infer<typeof changeQMSchema>
+
+interface ChangeQMManagerDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ investigationId: number
+ currentQMManagerId?: number
+ currentQMManagerName?: string
+ onSuccess?: () => void
+}
+
+export function ChangeQMManagerDialog({
+ isOpen,
+ onClose,
+ investigationId,
+ currentQMManagerId,
+ currentQMManagerName,
+ onSuccess,
+}: ChangeQMManagerDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const [qmManagers, setQMManagers] = React.useState<QMUser[]>([])
+ const [isLoadingManagers, setIsLoadingManagers] = React.useState(false)
+
+ // form 객체 생성
+ const form = useForm<ChangeQMFormValues>({
+ resolver: zodResolver(changeQMSchema),
+ defaultValues: {
+ qmManagerId: currentQMManagerId || undefined,
+ },
+ })
+
+ // Dialog가 열릴 때마다 초기값으로 폼 재설정
+ React.useEffect(() => {
+ if (isOpen) {
+ form.reset({
+ qmManagerId: currentQMManagerId || undefined,
+ });
+ }
+ }, [isOpen, currentQMManagerId, form]);
+
+ // Dialog가 열릴 때 QM 담당자 목록 로드
+ React.useEffect(() => {
+ if (isOpen && qmManagers.length === 0) {
+ const loadQMManagers = async () => {
+ setIsLoadingManagers(true)
+ try {
+ const result = await getQMManagers()
+ if (result.success && result.data) {
+ setQMManagers(result.data)
+ }
+ } catch (error) {
+ console.error("QM 담당자 로드 오류:", error)
+ } finally {
+ setIsLoadingManagers(false)
+ }
+ }
+
+ loadQMManagers()
+ }
+ }, [isOpen, qmManagers.length])
+
+ async function handleSubmit(data: ChangeQMFormValues) {
+ setIsPending(true)
+ try {
+ const result = await updateQMManagerAction({
+ investigationId,
+ qmManagerId: data.qmManagerId,
+ })
+
+ if (result.success) {
+ toast.success(result.message || "QM 담당자가 변경되었습니다.")
+ onClose()
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "QM 담당자 변경에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("QM 담당자 변경 오류:", error)
+ toast.error("QM 담당자 변경 중 오류가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
+ <DialogContent className="sm:max-w-[500px]">
+ <DialogHeader>
+ <DialogTitle>QM 담당자 변경</DialogTitle>
+ <DialogDescription>
+ 실사의 QM 담당자를 변경합니다.
+ {currentQMManagerName && (
+ <div className="mt-2 text-sm text-muted-foreground">
+ 현재 담당자: {currentQMManagerName}
+ </div>
+ )}
+ </DialogDescription>
+ </DialogHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
+ <FormField
+ control={form.control}
+ name="qmManagerId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>QM 담당자</FormLabel>
+ <FormControl>
+ <UserCombobox
+ users={qmManagers}
+ value={field.value}
+ onChange={field.onChange}
+ placeholder={isLoadingManagers ? "담당자 로딩 중..." : "담당자 선택..."}
+ disabled={isPending || isLoadingManagers}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending || isLoadingManagers}>
+ {isPending ? "변경 중..." : "변경"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx
index 9f4944c3..03e66076 100644
--- a/lib/vendor-investigation/table/investigation-table-columns.tsx
+++ b/lib/vendor-investigation/table/investigation-table-columns.tsx
@@ -5,7 +5,7 @@ import { ColumnDef } from "@tanstack/react-table"
import { Checkbox } from "@/components/ui/checkbox"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
-import { Edit, Ellipsis, AlertTriangle, FileEdit, Eye } from "lucide-react"
+import { Edit, Ellipsis, AlertTriangle, FileEdit, Eye, FileText } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
@@ -145,7 +145,7 @@ export function getColumns({
<DropdownMenuItem
onSelect={async () => {
- if (isCanceled || row.original.investigationStatus !== "IN_PROGRESS") return
+ if (isCanceled || (row.original.investigationStatus !== "IN_PROGRESS" && row.original.investigationStatus !== "SUPPLEMENT_REQUIRED")) return
// 구매자체평가일 경우 결과입력 비활성화
if (row.original.investigationMethod === "PURCHASE_SELF_EVAL") {
return
@@ -170,7 +170,7 @@ export function getColumns({
}}
disabled={
isCanceled ||
- row.original.investigationStatus !== "IN_PROGRESS" ||
+ (row.original.investigationStatus !== "IN_PROGRESS" && row.original.investigationStatus !== "SUPPLEMENT_REQUIRED") ||
row.original.investigationMethod === "PURCHASE_SELF_EVAL"
}
>
@@ -178,6 +178,19 @@ export function getColumns({
실사 결과 입력
</DropdownMenuItem>
+ {/* 실사 실시 확정 정보 버튼 - 제품검사평가 또는 방문실사평가인 경우 */}
+ {(row.original.investigationMethod === "PRODUCT_INSPECTION" ||
+ row.original.investigationMethod === "SITE_VISIT_EVAL") && (
+ <DropdownMenuItem
+ onSelect={() => {
+ (setRowAction as any)?.({ type: "vendor-info-view", row })
+ }}
+ >
+ <FileText className="mr-2 h-4 w-4" />
+ 실사 실시 확정 정보
+ </DropdownMenuItem>
+ )}
+
{canRequestSupplement && (
<>
<DropdownMenuSeparator />
@@ -331,6 +344,11 @@ export function getColumns({
return value ? `#${value}` : ""
}
+ // Handle pqNumber
+ if (column.id === "pqNumber") {
+ return value ? (value as string) : <span className="text-muted-foreground">-</span>
+ }
+
// Handle file attachment status
if (column.id === "hasAttachments") {
return (
diff --git a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx
index 991c1ad6..371873e4 100644
--- a/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx
+++ b/lib/vendor-investigation/table/investigation-table-toolbar-actions.tsx
@@ -2,13 +2,14 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, RotateCcw } from "lucide-react"
+import { Download, RotateCcw, UserCog } from "lucide-react"
import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig"
import { InvestigationCancelPlanButton } from "./investigation-cancel-plan-button"
+import { ChangeQMManagerDialog } from "./change-qm-manager-dialog"
interface VendorsTableToolbarActionsProps {
@@ -16,13 +17,46 @@ interface VendorsTableToolbarActionsProps {
}
export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
+ // 선택된 행 분석
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const selectedData = selectedRows.map(row => row.original)
+ // QM 담당자 변경 가능 여부 체크 (선택된 항목이 1개이고 상태가 PLANNED)
+ const canChangeQM = selectedData.length === 1 && selectedData[0].investigationStatus === "PLANNED"
+ const selectedInvestigation = canChangeQM ? selectedData[0] : null
+
+ // QM 담당자 변경 다이얼로그 상태
+ const [showChangeQMDialog, setShowChangeQMDialog] = React.useState(false)
+
+ const handleChangeQMClick = () => {
+ if (canChangeQM) {
+ setShowChangeQMDialog(true)
+ }
+ }
+
+ const handleChangeQMSuccess = () => {
+ // 테이블 선택 초기화
+ table.toggleAllPageRowsSelected(false)
+ setShowChangeQMDialog(false)
+ }
return (
<div className="flex items-center gap-2">
<InvestigationCancelPlanButton table={table} />
+ {/* QM 담당자 변경 버튼 - 계획 상태이고 단일 선택일 때만 활성화 */}
+ {canChangeQM && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleChangeQMClick}
+ className="gap-2"
+ >
+ <UserCog className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">QM 담당자 변경</span>
+ </Button>
+ )}
+
{/** 4) Export 버튼 */}
<Button
variant="outline"
@@ -38,6 +72,18 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions
<Download className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">Export</span>
</Button>
+
+ {/* QM 담당자 변경 다이얼로그 */}
+ {selectedInvestigation && (
+ <ChangeQMManagerDialog
+ isOpen={showChangeQMDialog}
+ onClose={() => setShowChangeQMDialog(false)}
+ investigationId={selectedInvestigation.investigationId}
+ currentQMManagerId={selectedInvestigation.qmManagerId}
+ currentQMManagerName={selectedInvestigation.qmManagerName}
+ onSuccess={handleChangeQMSuccess}
+ />
+ )}
</div>
)
} \ No newline at end of file
diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx
index ee122f04..2179669f 100644
--- a/lib/vendor-investigation/table/investigation-table.tsx
+++ b/lib/vendor-investigation/table/investigation-table.tsx
@@ -20,6 +20,7 @@ import { InvestigationResultSheet } from "./investigation-result-sheet"
import { InvestigationProgressSheet } from "./investigation-progress-sheet"
import { VendorDetailsDialog } from "./vendor-details-dialog"
import { SupplementRequestDialog } from "@/components/investigation/supplement-request-dialog"
+import { VendorInfoViewDialog } from "@/lib/site-visit/vendor-info-view-dialog"
interface VendorsTableProps {
promises: Promise<
@@ -52,6 +53,16 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
// Add state for row actions
const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorInvestigationsViewWithContacts> | null>(null)
+ // Handle row actions
+ React.useEffect(() => {
+ if (rowAction?.type === "vendor-info-view") {
+ // 협력업체 정보 조회 다이얼로그 열기
+ setSelectedInvestigationId(rowAction.row.original.investigationId)
+ setIsVendorInfoViewDialogOpen(true)
+ setRowAction(null)
+ }
+ }, [rowAction])
+
// Add state for vendor details dialog
const [vendorDetailsOpen, setVendorDetailsOpen] = React.useState(false)
const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null)
@@ -64,6 +75,11 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
vendorName: string
} | null>(null)
+ // Add state for vendor info view dialog
+ const [isVendorInfoViewDialogOpen, setIsVendorInfoViewDialogOpen] = React.useState(false)
+ const [selectedSiteVisitRequestId, setSelectedSiteVisitRequestId] = React.useState<number | null>(null)
+ const [selectedInvestigationId, setSelectedInvestigationId] = React.useState<number | null>(null)
+
// Create handler for opening vendor details modal
const openVendorDetailsModal = React.useCallback((vendorId: number) => {
setSelectedVendorId(vendorId)
@@ -226,6 +242,18 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) {
investigationMethod={supplementRequestData?.investigationMethod || ""}
vendorName={supplementRequestData?.vendorName || ""}
/>
+
+ {/* Vendor Info View Dialog */}
+ <VendorInfoViewDialog
+ isOpen={isVendorInfoViewDialogOpen}
+ onClose={() => {
+ setIsVendorInfoViewDialogOpen(false)
+ setSelectedSiteVisitRequestId(null)
+ setSelectedInvestigationId(null)
+ }}
+ siteVisitRequestId={selectedSiteVisitRequestId}
+ investigationId={selectedInvestigationId}
+ />
</>
)
} \ No newline at end of file