summaryrefslogtreecommitdiff
path: root/lib/pq
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pq')
-rw-r--r--lib/pq/pq-review-table-new/site-visit-dialog.tsx795
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx99
-rw-r--r--lib/pq/pq-review-table-new/vendors-table.tsx32
-rw-r--r--lib/pq/service.ts5
4 files changed, 644 insertions, 287 deletions
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);