diff options
Diffstat (limited to 'lib/vendor-investigation')
5 files changed, 458 insertions, 25 deletions
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 |
