summaryrefslogtreecommitdiff
path: root/lib/site-visit
diff options
context:
space:
mode:
Diffstat (limited to 'lib/site-visit')
-rw-r--r--lib/site-visit/service.ts131
-rw-r--r--lib/site-visit/vendor-info-view-dialog.tsx110
2 files changed, 219 insertions, 22 deletions
diff --git a/lib/site-visit/service.ts b/lib/site-visit/service.ts
index d5e4a59b..1dc07c77 100644
--- a/lib/site-visit/service.ts
+++ b/lib/site-visit/service.ts
@@ -18,6 +18,137 @@ import { users } from "@/db/schema"
+// 실사 ID로 모든 siteVisitRequests 조회 (복수 확정정보 지원)
+export async function getAllSiteVisitRequestsForInvestigationAction(investigationId: number) {
+ try {
+ const confirmations = await db
+ .select({
+ id: siteVisitRequests.id,
+ status: siteVisitRequests.status,
+ inspectionDuration: siteVisitRequests.inspectionDuration,
+ requestedStartDate: siteVisitRequests.requestedStartDate,
+ requestedEndDate: siteVisitRequests.requestedEndDate,
+ additionalRequests: siteVisitRequests.additionalRequests,
+ createdAt: siteVisitRequests.createdAt,
+ updatedAt: siteVisitRequests.updatedAt,
+ })
+ .from(siteVisitRequests)
+ .where(eq(siteVisitRequests.investigationId, investigationId))
+ .orderBy(desc(siteVisitRequests.createdAt))
+
+ return { success: true, confirmations }
+ } catch (error) {
+ console.error("실사 확정정보 조회 오류:", error)
+ return { success: false, error: "실사 확정정보 조회에 실패했습니다." }
+ }
+}
+
+// 재방문 실사 요청을 위한 방문실사 생성 (동일 investigationId에 대해 여러 개 허용)
+export async function createReinspectionSiteVisitAction(input: {
+ investigationId: number;
+ inspectionDuration: number;
+ requestedStartDate: Date;
+ requestedEndDate: Date;
+ shiAttendees: Record<string, string>; // {userId: name}
+ vendorRequests: Record<string, boolean>;
+ additionalRequests?: string;
+ investigationAddress: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("Unauthorized");
+ }
+
+ // 실사 정보 확인
+ const investigation = await db
+ .select()
+ .from(vendorInvestigations)
+ .where(eq(vendorInvestigations.id, input.investigationId))
+ .limit(1);
+
+ if (!investigation.length) {
+ return {
+ success: false,
+ error: "실사 정보를 찾을 수 없습니다."
+ };
+ }
+
+ // PQ 정보 확인
+ const pqSubmission = await db
+ .select()
+ .from(vendorPQSubmissions)
+ .where(eq(vendorPQSubmissions.id, investigation[0].pqSubmissionId!))
+ .limit(1);
+
+ if (!pqSubmission.length) {
+ return {
+ success: false,
+ error: "PQ 정보를 찾을 수 없습니다."
+ };
+ }
+
+ // 방문실사 요청 생성 (재실사이므로 기존 확인 로직 생략)
+ const [newRequest] = await db
+ .insert(siteVisitRequests)
+ .values({
+ investigationId: input.investigationId,
+ inspectionDuration: input.inspectionDuration,
+ requestedStartDate: input.requestedStartDate,
+ requestedEndDate: input.requestedEndDate,
+ shiAttendees: input.shiAttendees,
+ vendorRequests: input.vendorRequests,
+ additionalRequests: input.additionalRequests,
+ status: "REQUESTED",
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ // 벤더에게 이메일 발송
+ const vendor = await db
+ .select({ email: vendors.email, vendorName: vendors.vendorName })
+ .from(vendors)
+ .where(eq(vendors.id, investigation[0].vendorId))
+ .limit(1);
+
+ if (vendor.length && vendor[0].email) {
+ const headersList = await import("next/headers").then(m => m.headers());
+ const host = headersList.get('host') || 'localhost:3000';
+ const portalUrl = process.env.NEXTAUTH_URL || `http://${host}`;
+
+ await sendEmail({
+ to: vendor[0].email,
+ subject: `[eVCP] 재실사 방문요청 - ${vendor[0].vendorName}`,
+ template: "site-visit-request",
+ context: {
+ vendorName: vendor[0].vendorName,
+ inspectionDuration: input.inspectionDuration,
+ requestedStartDate: format(input.requestedStartDate, "yyyy년 MM월 dd일"),
+ requestedEndDate: format(input.requestedEndDate, "yyyy년 MM월 dd일"),
+ investigationAddress: input.investigationAddress,
+ additionalRequests: input.additionalRequests || "",
+ reviewUrl: `${portalUrl}/evcp/vendor-investigation`,
+ year: new Date().getFullYear(),
+ }
+ });
+ }
+
+ revalidatePath("/evcp/vendor-investigation");
+
+ return {
+ success: true,
+ siteVisitRequestId: newRequest.id
+ };
+ } catch (error) {
+ console.error("재실사 방문요청 생성 실패:", error);
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "알 수 없는 오류"
+ };
+ }
+}
+
// 방문실사 요청 서버 액션
export async function createSiteVisitRequestAction(input: {
investigationId: number;
diff --git a/lib/site-visit/vendor-info-view-dialog.tsx b/lib/site-visit/vendor-info-view-dialog.tsx
index 431069b3..48aefeb0 100644
--- a/lib/site-visit/vendor-info-view-dialog.tsx
+++ b/lib/site-visit/vendor-info-view-dialog.tsx
@@ -15,6 +15,7 @@ import {
} from "@/components/ui/dialog"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
+import { Badge } from "@/components/ui/badge"
import { toast } from "sonner"
interface VendorInfo {
@@ -53,46 +54,63 @@ interface VendorInfoViewDialogProps {
isOpen: boolean
onClose: () => void
siteVisitRequestId: number | null
+ investigationId?: number | null // 실사 ID 추가 - 여러 확정정보 조회용
+ isReinspection?: boolean // 재실사 모드 플래그
}
export function VendorInfoViewDialog({
isOpen,
onClose,
siteVisitRequestId,
+ investigationId,
}: VendorInfoViewDialogProps) {
const [data, setData] = React.useState<VendorInfo | null>(null)
const [attachments, setAttachments] = React.useState<Attachment[]>([])
+ const [allConfirmations, setAllConfirmations] = React.useState<any[]>([]) // 여러 확정정보
const [isLoading, setIsLoading] = React.useState(false)
// 데이터 로드
React.useEffect(() => {
- if (isOpen && siteVisitRequestId) {
- loadVendorInfo()
+ if (isOpen && (siteVisitRequestId || investigationId)) {
+ loadData()
}
- }, [isOpen, siteVisitRequestId])
+ }, [isOpen, siteVisitRequestId, investigationId])
- const loadVendorInfo = async () => {
- if (!siteVisitRequestId) return
+ const loadData = async () => {
+ if (!siteVisitRequestId && !investigationId) return
setIsLoading(true)
try {
- const { getVendorSiteVisitInfoAction } = await import("./service")
- const result = await getVendorSiteVisitInfoAction(siteVisitRequestId)
-
- if (result.success && result.data) {
- setData(result.data.vendorInfo)
- setAttachments(result.data.attachments || [])
- } else {
- toast.error("협력업체 정보를 불러올 수 없습니다.")
+ // 단일 확정정보 조회 (기존)
+ if (siteVisitRequestId) {
+ const { getVendorSiteVisitInfoAction } = await import("./service")
+ const result = await getVendorSiteVisitInfoAction(siteVisitRequestId)
+
+ if (result.success && result.data) {
+ setData(result.data.vendorInfo)
+ setAttachments(result.data.attachments || [])
+ } else {
+ toast.error("협력업체 정보를 불러올 수 없습니다.")
+ }
+ }
+
+ // 여러 확정정보 조회 (신규 - 실사 ID로 모든 siteVisitRequests 조회)
+ if (investigationId) {
+ const { getAllSiteVisitRequestsForInvestigationAction } = await import("./service")
+ const result = await getAllSiteVisitRequestsForInvestigationAction(investigationId)
+ if (result.success) {
+ setAllConfirmations(result.confirmations || [])
+ }
}
} catch (error) {
- console.error("협력업체 정보 로드 오류:", error)
- toast.error("협력업체 정보를 불러오는 중 오류가 발생했습니다.")
+ console.error("데이터 로드 오류:", error)
+ toast.error("데이터를 불러오는 중 오류가 발생했습니다.")
} finally {
setIsLoading(false)
}
}
+
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
@@ -110,10 +128,11 @@ export function VendorInfoViewDialog({
<p className="text-muted-foreground">협력업체 정보를 불러오는 중...</p>
</div>
</div>
- ) : data ? (
+ ) : (data || allConfirmations.length > 0) ? (
<div className="space-y-6">
- {/* 협력업체 정보 */}
- <Card>
+ {/* 협력업체 정보 - 단일 확정정보 조회 시에만 표시 */}
+ {data && (
+ <Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 className="h-5 w-5" />
@@ -173,6 +192,7 @@ export function VendorInfoViewDialog({
</div>
</CardContent>
</Card>
+ )}
{/* 첨부파일 */}
{attachments.length > 0 && (
@@ -226,8 +246,54 @@ export function VendorInfoViewDialog({
</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 && (
+ {data?.otherInfo && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -236,7 +302,7 @@ export function VendorInfoViewDialog({
</CardTitle>
</CardHeader>
<CardContent>
- <p className="text-sm whitespace-pre-wrap">{data.otherInfo}</p>
+ <p className="text-sm whitespace-pre-wrap">{data?.otherInfo}</p>
</CardContent>
</Card>
)}
@@ -253,8 +319,8 @@ export function VendorInfoViewDialog({
<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><span className="font-medium">제출일:</span> {formatDate(data?.submittedAt, "kr")}</div>
+ <div><span className="font-medium">첨부파일:</span> {data?.hasAttachments ? "있음" : "없음"}</div>
</div>
</div>
</div>