summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/ship/revision-upload-dialog.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list/ship/revision-upload-dialog.tsx')
-rw-r--r--lib/vendor-document-list/ship/revision-upload-dialog.tsx629
1 files changed, 629 insertions, 0 deletions
diff --git a/lib/vendor-document-list/ship/revision-upload-dialog.tsx b/lib/vendor-document-list/ship/revision-upload-dialog.tsx
new file mode 100644
index 00000000..16fc9fbb
--- /dev/null
+++ b/lib/vendor-document-list/ship/revision-upload-dialog.tsx
@@ -0,0 +1,629 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { toast } from "sonner"
+import { useRouter } from "next/navigation"
+import { useSession } from "next-auth/react"
+import { mutate } from "swr" // ✅ SWR mutate import 추가
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import {
+ Dropzone,
+ DropzoneDescription,
+ DropzoneInput,
+ DropzoneTitle,
+ DropzoneUploadIcon,
+ DropzoneZone,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListAction,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListItem,
+ FileListName,
+ FileListSize,
+} from "@/components/ui/file-list"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import { Upload, X, Loader2 } from "lucide-react"
+import prettyBytes from "pretty-bytes"
+import { EnhancedDocumentsView } from "@/db/schema/vendorDocu"
+
+// 리비전 업로드 스키마
+const revisionUploadSchema = z.object({
+ stage: z.string().min(1, "스테이지는 필수입니다"),
+ revision: z.string().min(1, "리비전은 필수입니다"),
+ uploaderName: z.string().optional(),
+ comment: z.string().optional(),
+ attachments: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"),
+ // ✅ B3 문서용 usage 필드 추가
+ usage: z.string().optional(),
+}).refine((data) => {
+ // B3 문서이고 특정 stage인 경우 usage 필수
+ // 이 검증은 컴포넌트 내에서 조건부로 처리
+ return true;
+}, {
+ message: "Usage는 필수입니다",
+ path: ["usage"],
+});
+
+const getUsageOptions = (stageName: string): string[] => {
+ const stageNameLower = stageName.toLowerCase();
+
+ if (stageNameLower.includes('approval')) {
+ return ['Approval (Partial)', 'Approval (Full)'];
+ } else if (stageNameLower.includes('working')) {
+ return ['Working (Partial)', 'Working (Full)'];
+ }
+
+ return [];
+};
+
+
+type RevisionUploadSchema = z.infer<typeof revisionUploadSchema>
+
+interface RevisionUploadDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ document: EnhancedDocumentsView | null
+ projectType: "ship" | "plant"
+ presetStage?: string
+ presetRevision?: string
+ mode?: 'new' | 'append'
+ onUploadComplete?: () => void // ✅ 업로드 완료 콜백 추가
+}
+
+function getTargetSystem(projectType: "ship" | "plant") {
+ return projectType === "ship" ? "DOLCE" : "SWP"
+}
+
+export function RevisionUploadDialog({
+ open,
+ onOpenChange,
+ document,
+ projectType,
+ presetStage,
+ presetRevision,
+ mode = 'new',
+ onUploadComplete,
+}: RevisionUploadDialogProps) {
+
+ const targetSystem = React.useMemo(
+ () => getTargetSystem(projectType),
+ [projectType]
+ )
+
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+ const [isUploading, setIsUploading] = React.useState(false)
+ const [uploadProgress, setUploadProgress] = React.useState(0)
+ const router = useRouter()
+
+ const { data: session } = useSession()
+
+ // 사용 가능한 스테이지 옵션
+ const stageOptions = React.useMemo(() => {
+ if (document?.allStages) {
+ return document.allStages.map(stage => stage.stageName)
+ }
+ return ["Issued for Review", "AFC", "Final Issue"]
+ }, [document])
+
+ const form = useForm<RevisionUploadSchema>({
+ resolver: zodResolver(revisionUploadSchema),
+ defaultValues: {
+ stage: presetStage || document?.currentStageName || "",
+ revision: presetRevision || "",
+ uploaderName: session?.user?.name || "",
+ comment: "",
+ attachments: [],
+ usage: "", // ✅ usage 기본값 추가
+ },
+ })
+
+ // ✅ 현재 선택된 stage 값을 watch
+ const currentStage = form.watch('stage')
+
+ // ✅ B3 문서 여부 확인
+ const isB3Document = document?.drawingKind === 'B3'
+
+ // ✅ 현재 stage에 따른 usage 옵션
+ const usageOptions = React.useMemo(() => {
+ if (!isB3Document || !currentStage) return []
+ return getUsageOptions(currentStage)
+ }, [isB3Document, currentStage])
+
+ // ✅ usage 필드가 필요한지 확인
+ const isUsageRequired = isB3Document && usageOptions.length > 0
+
+ // session이 로드되면 uploaderName 업데이트
+ React.useEffect(() => {
+ if (session?.user?.name) {
+ form.setValue('uploaderName', session.user.name)
+ }
+ }, [session?.user?.name, form])
+
+ // presetStage와 presetRevision이 변경될 때 폼 값 업데이트
+ React.useEffect(() => {
+ if (presetStage) {
+ form.setValue('stage', presetStage)
+ }
+ if (presetRevision) {
+ form.setValue('revision', presetRevision)
+ }
+ }, [presetStage, presetRevision, form])
+
+ // ✅ stage가 변경될 때 usage 값 리셋
+ React.useEffect(() => {
+ if (isB3Document) {
+ const newUsageOptions = getUsageOptions(currentStage)
+ if (newUsageOptions.length === 0) {
+ form.setValue('usage', '')
+ } else {
+ // 기존 값이 새로운 옵션에 없으면 리셋
+ const currentUsage = form.getValues('usage')
+ if (currentUsage && !newUsageOptions.includes(currentUsage)) {
+ form.setValue('usage', '')
+ }
+ }
+ }
+ }, [currentStage, isB3Document, form])
+
+ // 파일 드롭 처리
+ const handleDropAccepted = (acceptedFiles: File[]) => {
+ const newFiles = [...selectedFiles, ...acceptedFiles]
+ setSelectedFiles(newFiles)
+ form.setValue('attachments', newFiles, { shouldValidate: true })
+ }
+
+ const removeFile = (index: number) => {
+ const updatedFiles = [...selectedFiles]
+ updatedFiles.splice(index, 1)
+ setSelectedFiles(updatedFiles)
+ form.setValue('attachments', updatedFiles, { shouldValidate: true })
+ }
+
+ // 캐시 갱신 함수
+ const refreshCaches = async () => {
+ try {
+ router.refresh()
+
+ if (document?.contractId) {
+ await mutate(`/api/sync/status/${document.contractId}/${targetSystem}`)
+ console.log('✅ Sync status cache refreshed')
+ }
+
+ await mutate(key =>
+ typeof key === 'string' &&
+ key.includes('sync') &&
+ key.includes(String(document?.contractId))
+ )
+
+ onUploadComplete?.()
+
+ console.log('✅ All caches refreshed after upload')
+ } catch (error) {
+ console.error('❌ Cache refresh failed:', error)
+ }
+ }
+
+ // ✅ 업로드 처리 - usage 필드 검증 및 전송
+ async function onSubmit(data: RevisionUploadSchema) {
+ if (!document) return
+
+ // ✅ B3 문서에서 usage가 필요한 경우 검증
+ if (isUsageRequired && !data.usage) {
+ form.setError('usage', {
+ type: 'required',
+ message: 'Usage 선택은 필수입니다'
+ })
+ return
+ }
+
+ setIsUploading(true)
+ setUploadProgress(0)
+
+ try {
+ const formData = new FormData()
+ formData.append("documentId", String(document.documentId))
+ formData.append("stage", data.stage)
+ formData.append("revision", data.revision)
+ formData.append("mode", mode)
+ formData.append("targetSystem", targetSystem)
+
+ if (data.uploaderName) {
+ formData.append("uploaderName", data.uploaderName)
+ }
+
+ if (data.comment) {
+ formData.append("comment", data.comment)
+ }
+
+ // ✅ B3 문서인 경우 usage 추가
+ if (isB3Document && data.usage) {
+ formData.append("usage", data.usage)
+ }
+
+ // 파일들 추가
+ data.attachments.forEach((file) => {
+ formData.append("attachments", file)
+ })
+
+ // 진행률 업데이트 시뮬레이션
+ const updateProgress = (progress: number) => {
+ setUploadProgress(Math.min(progress, 95))
+ }
+
+ const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0)
+ let uploadedSize = 0
+
+ const progressInterval = setInterval(() => {
+ uploadedSize += totalSize * 0.1
+ const progress = Math.min((uploadedSize / totalSize) * 100, 90)
+ updateProgress(progress)
+ }, 300)
+
+ const response = await fetch('/api/revision-upload', {
+ method: 'POST',
+ body: formData,
+ })
+
+ clearInterval(progressInterval)
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || errorData.details || '업로드에 실패했습니다.')
+ }
+
+ const result = await response.json()
+ setUploadProgress(100)
+
+ toast.success(
+ result.message ||
+ `리비전 ${data.revision}이 성공적으로 업로드되었습니다. (${result.data?.uploadedFiles?.length || 0}개 파일)`
+ )
+
+ console.log('✅ 업로드 성공:', result)
+
+ setTimeout(async () => {
+ await refreshCaches()
+ handleDialogClose()
+ }, 1000)
+
+ } catch (error) {
+ console.error('❌ 업로드 오류:', error)
+ toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다")
+ } finally {
+ setIsUploading(false)
+ setTimeout(() => setUploadProgress(0), 2000)
+ }
+ }
+
+ const handleDialogClose = () => {
+ form.reset({
+ stage: presetStage || document?.currentStageName || "",
+ revision: presetRevision || "",
+ uploaderName: session?.user?.name || "",
+ comment: "",
+ attachments: [],
+ usage: "", // ✅ usage 리셋 추가
+ })
+ setSelectedFiles([])
+ setIsUploading(false)
+ setUploadProgress(0)
+ onOpenChange(false)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogClose}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <Upload className="w-5 h-5" />
+ {mode === 'new' ? '새 리비전 업로드' : '파일 추가'}
+ </DialogTitle>
+ <DialogDescription>
+ {document ? `${document.docNumber} - ${document.title}` :
+ mode === 'new' ? "문서에 새 리비전을 업로드합니다." : "기존 리비전에 파일을 추가합니다."}
+ </DialogDescription>
+
+ <div className="flex items-center gap-2 pt-2 flex-wrap">
+ <Badge variant={projectType === "ship" ? "default" : "secondary"}>
+ {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"}
+ </Badge>
+ <Badge variant="outline" className="text-xs">
+ → {targetSystem}
+ </Badge>
+ {/* ✅ B3 문서 표시 */}
+ {isB3Document && (
+ <Badge variant="outline" className="text-xs bg-orange-50 text-orange-700 border-orange-200">
+ B3 문서
+ </Badge>
+ )}
+ {session?.user?.name && (
+ <Badge variant="outline" className="text-xs">
+ 업로더: {session.user.name}
+ </Badge>
+ )}
+ {mode === 'append' && presetRevision && (
+ <Badge variant="outline" className="text-xs">
+ 리비전 {presetRevision}에 파일 추가
+ </Badge>
+ )}
+ {mode === 'new' && presetRevision && (
+ <Badge variant="outline" className="text-xs">
+ 다음 리비전: {presetRevision}
+ </Badge>
+ )}
+ </div>
+ </DialogHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
+ <div className="grid grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="stage"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>스테이지</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="스테이지 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {stageOptions.map((stage) => (
+ <SelectItem key={stage} value={stage}>
+ {stage}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="revision"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>리비전</FormLabel>
+ <FormControl>
+ <Input
+ {...field}
+ placeholder="예: A, B, 1, 2..."
+ readOnly={mode === 'append'}
+ className={mode === 'append' ? 'bg-gray-50' : ''}
+ />
+ </FormControl>
+ <FormMessage />
+ {mode === 'new' && presetRevision && (
+ <p className="text-xs text-gray-500">
+ 자동으로 계산된 다음 리비전입니다.
+ </p>
+ )}
+ {mode === 'append' && (
+ <p className="text-xs text-gray-500">
+ 기존 리비전에 파일을 추가합니다.
+ </p>
+ )}
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* ✅ B3 문서용 Usage 필드 - 조건부 표시 */}
+ {isB3Document && usageOptions.length > 0 && (
+ <FormField
+ control={form.control}
+ name="usage"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center gap-2">
+ 용도
+ {isUsageRequired && <span className="text-red-500">*</span>}
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="용도를 선택하세요" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {usageOptions.map((usage) => (
+ <SelectItem key={usage} value={usage}>
+ {usage}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ <p className="text-xs text-gray-500">
+ {currentStage} 스테이지에 필요한 용도를 선택하세요.
+ </p>
+ </FormItem>
+ )}
+ />
+ )}
+
+ <FormField
+ control={form.control}
+ name="uploaderName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업로더명</FormLabel>
+ <FormControl>
+ <Input
+ {...field}
+ placeholder="업로더 이름을 입력하세요"
+ className="bg-gray-50"
+ />
+ </FormControl>
+ <FormMessage />
+ <p className="text-xs text-gray-500">
+ 로그인된 사용자 정보가 자동으로 입력됩니다.
+ </p>
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="comment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>코멘트 (선택)</FormLabel>
+ <FormControl>
+ <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 파일 업로드 영역 */}
+ <FormField
+ control={form.control}
+ name="attachments"
+ render={() => (
+ <FormItem>
+ <FormLabel>파일 첨부</FormLabel>
+ <Dropzone
+ maxSize={3e9} // 3GB
+ multiple={true}
+ onDropAccepted={handleDropAccepted}
+ disabled={isUploading}
+ >
+ <DropzoneZone className="flex justify-center">
+ <FormControl>
+ <DropzoneInput />
+ </FormControl>
+ <div className="flex items-center gap-6">
+ <DropzoneUploadIcon />
+ <div className="grid gap-0.5">
+ <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle>
+ <DropzoneDescription>
+ 또는 클릭하여 파일을 선택하세요
+ </DropzoneDescription>
+ </div>
+ </div>
+ </DropzoneZone>
+ </Dropzone>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 선택된 파일 목록 */}
+ {selectedFiles.length > 0 && (
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <h6 className="text-sm font-semibold">
+ 선택된 파일 ({selectedFiles.length})
+ </h6>
+ </div>
+ <ScrollArea className="max-h-[200px]">
+ <FileList>
+ {selectedFiles.map((file, index) => (
+ <FileListItem key={index} className="p-3">
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListSize>{file.size}</FileListSize>
+ </FileListInfo>
+ <FileListAction
+ onClick={() => removeFile(index)}
+ disabled={isUploading}
+ >
+ <X className="h-4 w-4" />
+ </FileListAction>
+ </FileListHeader>
+ </FileListItem>
+ ))}
+ </FileList>
+ </ScrollArea>
+ </div>
+ )}
+
+ {/* 업로드 진행 상태 */}
+ {isUploading && (
+ <div className="space-y-2">
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ <span className="text-sm">{uploadProgress}% 업로드 중...</span>
+ </div>
+ <div className="h-2 w-full bg-muted rounded-full overflow-hidden">
+ <div
+ className="h-full bg-primary rounded-full transition-all"
+ style={{ width: `${uploadProgress}%` }}
+ />
+ </div>
+ </div>
+ )}
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleDialogClose}
+ disabled={isUploading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isUploading || selectedFiles.length === 0}
+ >
+ {isUploading ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ 업로드 중...
+ </>
+ ) : (
+ <>
+ <Upload className="mr-2 h-4 w-4" />
+ 업로드
+ </>
+ )}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file