diff options
Diffstat (limited to 'lib')
19 files changed, 2999 insertions, 657 deletions
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 |
