summaryrefslogtreecommitdiff
path: root/lib/site-visit
diff options
context:
space:
mode:
Diffstat (limited to 'lib/site-visit')
-rw-r--r--lib/site-visit/client-site-visit-wrapper.tsx21
-rw-r--r--lib/site-visit/service.ts239
-rw-r--r--lib/site-visit/shi-attendees-dialog.tsx79
-rw-r--r--lib/site-visit/vendor-info-view-dialog.tsx582
4 files changed, 645 insertions, 276 deletions
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