diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:39:21 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-04 09:39:21 +0000 |
| commit | 53ad72732f781e6c6d5ddb3776ea47aec010af8e (patch) | |
| tree | e676287827f8634be767a674b8ad08b6ed7eb3e6 /lib/pq/pq-review-table-new/site-visit-dialog.tsx | |
| parent | 3e4d15271322397764601dee09441af8a5b3adf5 (diff) | |
(최겸) PQ/실사 수정 및 개발
Diffstat (limited to 'lib/pq/pq-review-table-new/site-visit-dialog.tsx')
| -rw-r--r-- | lib/pq/pq-review-table-new/site-visit-dialog.tsx | 711 |
1 files changed, 711 insertions, 0 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 new file mode 100644 index 00000000..63390cb1 --- /dev/null +++ b/lib/pq/pq-review-table-new/site-visit-dialog.tsx @@ -0,0 +1,711 @@ +"use client"
+
+import * as React from "react"
+import { CalendarIcon, X } from "lucide-react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { format } from "date-fns"
+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,
+ FormDescription,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+
+import { Calendar } from "@/components/ui/calendar"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import { toast } from "sonner"
+import { getSiteVisitRequestAction } from "@/lib/site-visit/service"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+
+// 방문실사 요청 폼 스키마
+const siteVisitRequestSchema = z.object({
+ // 실사 기간
+ inspectionDuration: z.number().min(0.5, "실사 기간을 입력해주세요."),
+
+ // 실사 요청일
+ requestedStartDate: z.date({
+ required_error: "실사 시작일을 선택해주세요.",
+ }),
+ 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: "" }),
+ 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: "" }),
+ 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: "" }),
+ 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: "" }),
+ 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: "" }),
+ 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: "" }),
+ 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: "" }),
+ }),
+
+ // SHI 참석자 정보 (JSON 형태로 저장) - 기존 필드 유지
+ shiAttendeeDetails: z.string().optional(),
+
+ // 협력업체 요청정보 및 자료
+ vendorRequests: z.object({
+ availableDates: z.boolean().default(false),
+ factoryName: z.boolean().default(false),
+ factoryLocation: z.boolean().default(false),
+ factoryAddress: z.boolean().default(false),
+ factoryPicName: z.boolean().default(false),
+ factoryPicPhone: z.boolean().default(false),
+ factoryPicEmail: z.boolean().default(false),
+ factoryDirections: z.boolean().default(false),
+ accessProcedure: z.boolean().default(false),
+ other: z.boolean().default(false),
+ }),
+
+ // 기타 요청사항
+ otherVendorRequests: z.string().optional(),
+
+ // 추가 요청사항
+ additionalRequests: z.string().optional(),
+})
+
+type SiteVisitRequestFormValues = z.infer<typeof siteVisitRequestSchema>
+
+interface SiteVisitDialogProps {
+ isOpen: boolean
+ onClose: () => void
+ onSubmit: (data: SiteVisitRequestFormValues, attachments?: File[]) => Promise<void>
+ investigation: {
+ id: number
+ evaluationType: "PRODUCT_INSPECTION" | "SITE_VISIT_EVAL"
+ investigationMethod?: string
+ investigationAddress?: string
+ vendorName: string
+ vendorCode: string
+ projectName?: string
+ projectCode?: string
+ pqItems?: string | null
+ }
+}
+
+export function SiteVisitDialog({
+ isOpen,
+ onClose,
+ onSubmit,
+ investigation,
+}: SiteVisitDialogProps) {
+ const [isPending, setIsPending] = React.useState(false)
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+
+ const form = useForm<SiteVisitRequestFormValues>({
+ resolver: zodResolver(siteVisitRequestSchema),
+ defaultValues: {
+ inspectionDuration: 1.0,
+ 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: "" },
+ },
+ shiAttendeeDetails: "",
+ vendorRequests: {
+ availableDates: false,
+ factoryName: false,
+ factoryLocation: false,
+ factoryAddress: false,
+ factoryPicName: false,
+ factoryPicPhone: false,
+ factoryPicEmail: false,
+ factoryDirections: false,
+ accessProcedure: false,
+ other: false,
+ },
+ otherVendorRequests: "",
+ additionalRequests: "",
+ },
+ })
+
+ // Dialog가 열릴 때마다 폼 재설정 및 기존 요청 확인
+ React.useEffect(() => {
+ if (isOpen) {
+ // 기존 방문실사 요청이 있는지 확인
+ const checkExistingRequest = async () => {
+ try {
+ const existingRequest = await getSiteVisitRequestAction(investigation.id)
+
+ if (existingRequest.success && existingRequest.data) {
+ toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ onClose()
+ return
+ }
+ } catch (error) {
+ console.error("방문실사 요청 상태 확인 중 오류:", error)
+ toast.error("방문실사 요청 상태 확인 중 오류가 발생했습니다.")
+ onClose()
+ return
+ }
+ }
+
+ checkExistingRequest()
+
+ form.reset({
+ inspectionDuration: 1.0,
+ 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: "" },
+ },
+ shiAttendeeDetails: "",
+ vendorRequests: {
+ availableDates: false,
+ factoryName: false,
+ factoryLocation: false,
+ factoryAddress: false,
+ factoryPicName: false,
+ factoryPicPhone: false,
+ factoryPicEmail: false,
+ factoryDirections: false,
+ accessProcedure: false,
+ other: false,
+ },
+ otherVendorRequests: "",
+ additionalRequests: "",
+ })
+ setSelectedFiles([])
+ }
+ }, [isOpen, form, investigation.id, onClose])
+
+ async function handleSubmit(data: SiteVisitRequestFormValues) {
+ setIsPending(true)
+ try {
+ // 제출 전에 한 번 더 기존 요청이 있는지 확인
+ const existingRequest = await getSiteVisitRequestAction(investigation.id)
+
+ if (existingRequest.success && existingRequest.data) {
+ toast.error("이미 방문실사 요청이 존재합니다. 추가 요청은 불가능합니다.")
+ onClose()
+ return
+ }
+
+ await onSubmit(data, selectedFiles)
+ toast.success("방문실사 요청이 성공적으로 발송되었습니다.")
+ } catch (error) {
+ toast.error("방문실사 요청 발송 중 오류가 발생했습니다.")
+ console.error("방문실사 요청 오류:", error)
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ const handleDropAccepted = (files: File[]) => {
+ setSelectedFiles(prev => [...prev, ...files])
+ toast.success(`${files.length}개 파일이 추가되었습니다.`)
+ }
+
+ const handleDropRejected = (files: unknown[]) => {
+ toast.error(`${files.length}개 파일이 거부되었습니다. 파일 크기나 형식을 확인해주세요.`)
+ }
+
+ const removeFile = (index: number) => {
+ setSelectedFiles(prev => prev.filter((_, i) => i !== index))
+ }
+
+ const getEvaluationTypeLabel = (type: string) => {
+ switch (type) {
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return type
+ }
+ }
+
+ const getInvestigationMethodLabel = (method: string) => {
+ switch (method) {
+ case "PURCHASE_SELF_EVAL":
+ return "구매자체평가"
+ case "DOCUMENT_EVAL":
+ return "서류평가"
+ case "PRODUCT_INSPECTION":
+ return "제품검사평가"
+ case "SITE_VISIT_EVAL":
+ return "방문실사평가"
+ default:
+ return method
+ }
+ }
+
+ 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>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
+ {/* 대상업체 정보 */}
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <FormLabel className="text-sm font-medium">대상업체</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <div className="font-medium">{investigation.vendorName}</div>
+ <div className="text-sm text-muted-foreground">({investigation.vendorCode})</div>
+ </div>
+ </div>
+
+ <div>
+ <FormLabel className="text-sm font-medium">대상품목</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <div className="font-medium">{investigation.pqItems || "-"}</div>
+ </div>
+ </div>
+ </div>
+
+
+
+ {/* 실사방법 */}
+ <div>
+ <FormLabel className="text-sm font-medium">실사방법</FormLabel>
+ <div className="mt-1 p-3 bg-muted rounded-md">
+ <Badge variant="outline">
+ {getEvaluationTypeLabel(investigation.evaluationType)}
+ </Badge>
+ {investigation.investigationMethod && (
+ <div className="mt-2 text-sm text-muted-foreground">
+ {getInvestigationMethodLabel(investigation.investigationMethod)}
+ </div>
+ )}
+ </div>
+ </div>
+
+ {/* 실사기간 */}
+ <FormField
+ control={form.control}
+ name="inspectionDuration"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>실사기간 (W/D 기준)</FormLabel>
+ <div className="flex items-center gap-2">
+ <FormControl>
+ <Input
+ type="number"
+ step="0.5"
+ min="0.5"
+ placeholder="1.5"
+ {...field}
+ onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
+ disabled={isPending}
+ className="w-24"
+ />
+ </FormControl>
+ <span className="text-sm text-muted-foreground">일</span>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 실사요청일 */}
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="requestedStartDate"
+ 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>
+ )}
+ />
+
+ <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>
+ )}
+ />
+ </div>
+
+ {/* SHI 실사참석 예정부문 */}
+ <div>
+ <FormLabel className="text-sm font-medium">SHI 실사참석 예정부문 ※ 필수값</FormLabel>
+ <div className="text-sm text-muted-foreground mb-4">
+ 삼성중공업에 어떤 부문의 담당자가 몇 명 실사 참석 예정인지에 대한 정보를 입력하세요.
+ </div>
+
+ <div className="space-y-4">
+ {[
+ { key: "technicalSales", label: "기술영업" },
+ { key: "design", label: "설계" },
+ { key: "procurement", label: "구매" },
+ { key: "quality", label: "품질" },
+ { key: "production", label: "생산" },
+ { key: "commissioning", label: "시운전" },
+ { key: "other", label: "기타" },
+ ].map((item) => (
+ <div key={item.key} className="border rounded-lg p-4 space-y-3">
+ <div className="flex items-center space-x-3">
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.checked` as `shiAttendees.${typeof item.key}.checked`}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-center space-x-2 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value}
+ onCheckedChange={field.onChange}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormLabel className="text-sm font-medium">{item.label}</FormLabel>
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.count` as `shiAttendees.${typeof item.key}.count`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-sm">참석 인원</FormLabel>
+ <div className="flex items-center space-x-2">
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ placeholder="0"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+ disabled={isPending}
+ className="w-20"
+ />
+ </FormControl>
+ <span className="text-sm text-muted-foreground">명</span>
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`shiAttendees.${item.key}.details` as `shiAttendees.${typeof item.key}.details`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-sm">참석자 정보</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="부서 및 이름 등"
+ {...field}
+ disabled={isPending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* 전체 참석자 상세정보 */}
+ <FormField
+ control={form.control}
+ name="shiAttendeeDetails"
+ render={({ field }) => (
+ <FormItem className="mt-4">
+ <FormLabel>전체 참석자 상세정보 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="전체 참석 예정인력의 상세 정보를 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </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 `vendorRequests.${typeof item.key}`}
+ 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}
+ name="additionalRequests"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>추가 요청사항 (선택사항)</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 요청사항을 입력하세요"
+ {...field}
+ disabled={isPending}
+ className="min-h-[80px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 첨부파일 */}
+ <div>
+ <FormLabel className="text-sm font-medium">첨부파일 (선택사항)</FormLabel>
+ <div className="mt-2">
+ <Dropzone
+ maxSize={6e8} // 600MB
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ >
+ {() => (
+ <FormItem>
+ <DropzoneZone className="flex justify-center h-24">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 최대 크기: 600MB
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ <FormDescription>
+ 또는 클릭하여 파일을 선택하세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ </Dropzone>
+ {selectedFiles.length > 0 && (
+ <div className="mt-2 space-y-1">
+ {selectedFiles.map((file, index) => (
+ <div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
+ <span className="text-sm">{file.name}</span>
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeFile(index)}
+ disabled={isPending}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={onClose}
+ disabled={isPending}
+ >
+ 취소
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending ? "처리 중..." : "방문실사 요청 생성"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+}
\ No newline at end of file |
