diff options
Diffstat (limited to 'lib/vendor-document-list/plant')
4 files changed, 972 insertions, 342 deletions
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 726ea101..3811e668 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -32,7 +32,7 @@ import { } from "@/components/ui/select" import { Badge } from "@/components/ui/badge" import { DocumentStagesOnlyView } from "@/db/schema" -import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash} from "lucide-react" +import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash, CheckCircle, Download, AlertCircle} from "lucide-react" import { toast } from "sonner" import { getDocumentNumberTypes, @@ -43,7 +43,8 @@ import { createDocument, updateDocument, deleteDocuments, - updateStage + updateStage, + getDocumentClassOptionsByContract } from "./document-stages-service" import { type Row } from "@tanstack/react-table" @@ -59,6 +60,32 @@ import { DrawerTrigger, } from "@/components/ui/drawer" import { useRouter } from "next/navigation" +import { cn, formatDate } from "@/lib/utils" +import ExcelJS from 'exceljs' +import { Progress } from "@/components/ui/progress" +import { Alert, AlertDescription } from "@/components/ui/alert" + +const getStatusVariant = (status: string) => { + switch (status) { + case 'COMPLETED': return 'success' + case 'IN_PROGRESS': return 'default' + case 'SUBMITTED': return 'secondary' + case 'REJECTED': return 'destructive' + default: return 'outline' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return '계획' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출' + case 'COMPLETED': return '완료' + case 'REJECTED': return '반려' + default: return status + } +} + // ============================================================================= // 1. Add Document Dialog @@ -83,6 +110,10 @@ export function AddDocumentDialog({ const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([]) + + console.log(documentNumberTypes, "documentNumberTypes") + console.log(documentClassOptions, "documentClassOptions") + const [formData, setFormData] = React.useState({ documentNumberTypeId: "", documentClassId: "", @@ -103,8 +134,8 @@ export function AddDocumentDialog({ setIsLoading(true) try { const [typesResult, classesResult] = await Promise.all([ - getDocumentNumberTypes(), - getDocumentClasses() + getDocumentNumberTypes(contractId), + getDocumentClasses(contractId) ]) if (typesResult.success) { @@ -137,8 +168,8 @@ export function AddDocumentDialog({ const comboBoxPromises = configsResult.data .filter(config => config.codeGroup?.controlType === 'combobox') .map(async (config) => { - const optionsResult = await getComboBoxOptions(config.codeGroupId!) - return { + const optionsResult = await getComboBoxOptions(config.codeGroupId!, contractId) + return { codeGroupId: config.codeGroupId, options: optionsResult.success ? optionsResult.data : [] } @@ -328,7 +359,7 @@ export function AddDocumentDialog({ <SelectContent> {documentNumberTypes.map((type) => ( <SelectItem key={type.id} value={String(type.id)}> - {type.name} - {type.description} + {type.name} </SelectItem> ))} </SelectContent> @@ -408,7 +439,7 @@ export function AddDocumentDialog({ <SelectContent> {documentClasses.map((cls) => ( <SelectItem key={cls.id} value={String(cls.id)}> - {cls.code} - {cls.description} + {cls.value} </SelectItem> ))} </SelectContent> @@ -496,7 +527,7 @@ export function AddDocumentDialog({ } // ============================================================================= -// 2. Edit Document Dialog +// Edit Document Dialog (with improved stage plan date editing) // ============================================================================= interface EditDocumentDialogProps { open: boolean @@ -591,7 +622,7 @@ export function EditDocumentDialog({ return ( <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="sm:max-w-[600px] h-full flex flex-col"> + <SheetContent className="sm:max-w-[500px] h-full flex flex-col"> <SheetHeader className="flex-shrink-0"> <SheetTitle>Edit Document</SheetTitle> <SheetDescription> @@ -644,28 +675,54 @@ export function EditDocumentDialog({ <div className="grid gap-3"> {document.allStages .sort((a, b) => (a.stageOrder || 0) - (b.stageOrder || 0)) - .map((stage) => ( - <div key={stage.id} className="grid grid-cols-2 gap-3 items-center"> - <div> - <Label className="text-sm font-medium"> - {stage.stageName} - </Label> - <p className="text-xs text-gray-500"> - Status: {stage.stageStatus} - {stage.actualDate && ` | Completed: ${stage.actualDate}`} - </p> - </div> - <div className="grid gap-1"> - <Label className="text-xs text-gray-600">Plan Date</Label> - <Input - type="date" - value={formData.stagePlanDates[stage.id] || ""} - onChange={(e) => handleStagePlanDateChange(stage.id, e.target.value)} - className="text-sm" - /> - </div> - </div> - ))} + .map((stage) => { + const canEditPlanDate = stage.stageStatus === 'PLANNED' + return ( + <div key={stage.id} className="grid grid-cols-2 gap-3 items-center"> + <div> + <Label className="text-sm font-medium"> + {stage.stageName} + </Label> + <div className="flex items-center gap-2 mt-1"> + <Badge + variant={getStatusVariant(stage.stageStatus)} + className="text-xs" + > + {getStatusText(stage.stageStatus)} + </Badge> + {!canEditPlanDate && ( + <Badge variant="outline" className="text-xs"> + 편집 불가 + </Badge> + )} + </div> + {stage.actualDate && ( + <p className="text-xs text-gray-500 mt-1"> + 완료일: {formatDate(stage.actualDate)} + </p> + )} + </div> + <div className="grid gap-1"> + <Label className="text-xs text-gray-600">Plan Date</Label> + <Input + type="date" + value={formData.stagePlanDates[stage.id] || ""} + onChange={(e) => handleStagePlanDateChange(stage.id, e.target.value)} + disabled={!canEditPlanDate} + className={cn( + "text-sm", + !canEditPlanDate && "bg-gray-100 cursor-not-allowed" + )} + /> + {!canEditPlanDate && ( + <p className="text-xs text-gray-500"> + 계획 상태일 때만 수정 가능 + </p> + )} + </div> + </div> + ) + })} </div> </div> )} @@ -689,6 +746,10 @@ export function EditDocumentDialog({ // ============================================================================= // 3. Edit Stage Dialog // ============================================================================= + +// ============================================================================= +// Improved Edit Stage Dialog +// ============================================================================= interface EditStageDialogProps { open: boolean onOpenChange: (open: boolean) => void @@ -704,33 +765,31 @@ export function EditStageDialog({ }: EditStageDialogProps) { const [isLoading, setIsLoading] = React.useState(false) const [formData, setFormData] = React.useState({ - stageName: "", planDate: "", - actualDate: "", - stageStatus: "PLANNED", - assigneeName: "", - priority: "MEDIUM", notes: "" }) - // Load stage information by stageId - React.useEffect(() => { + // 현재 스테이지 정보 + const currentStage = React.useMemo(() => { if (document && stageId) { - const stage = document.allStages?.find(s => s.id === stageId) - if (stage) { - setFormData({ - stageName: stage.stageName || "", - planDate: stage.planDate || "", - actualDate: stage.actualDate || "", - stageStatus: stage.stageStatus || "PLANNED", - assigneeName: stage.assigneeName || "", - priority: stage.priority || "MEDIUM", - notes: stage.notes || "" - }) - } + return document.allStages?.find(s => s.id === stageId) || null } + return null }, [document, stageId]) + // Load stage information by stageId + React.useEffect(() => { + if (currentStage) { + setFormData({ + planDate: currentStage.planDate || "", + notes: currentStage.notes || "" + }) + } + }, [currentStage]) + + // 계획날짜 편집 가능 여부 확인 + const canEditPlanDate = currentStage?.stageStatus === 'PLANNED' + const handleSubmit = async () => { if (!stageId) return @@ -738,147 +797,137 @@ export function EditStageDialog({ try { const result = await updateStage({ stageId, - ...formData + planDate: formData.planDate, + notes: formData.notes }) if (result.success) { - toast.success("Stage updated successfully.") + toast.success("스테이지가 성공적으로 업데이트되었습니다.") onOpenChange(false) } else { - toast.error(result.error || "Error updating stage.") + toast.error(result.error || "스테이지 업데이트 중 오류가 발생했습니다.") } } catch (error) { - toast.error("Error updating stage.") + toast.error("스테이지 업데이트 중 오류가 발생했습니다.") } finally { setIsLoading(false) } } + if (!currentStage) { + return null + } + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[500px] h-[70vh] flex flex-col"> - <DialogHeader className="flex-shrink-0"> - <DialogTitle>Edit Stage</DialogTitle> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>스테이지 편집</DialogTitle> <DialogDescription> - You can modify stage information. + 스테이지의 계획 날짜와 노트를 수정할 수 있습니다. </DialogDescription> </DialogHeader> - <div className="flex-1 overflow-y-auto pr-2"> - <div className="grid gap-4 py-4"> - <div className="grid gap-2"> - <Label htmlFor="edit-stageName">Stage Name</Label> - <div className="p-2 bg-gray-100 rounded text-sm"> - {formData.stageName} - </div> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="grid gap-2"> - <Label htmlFor="edit-planDate"> - <Calendar className="inline w-4 h-4 mr-1" /> - Plan Date - </Label> - <Input - id="edit-planDate" - type="date" - value={formData.planDate} - onChange={(e) => setFormData({ ...formData, planDate: e.target.value })} - /> - </div> - <div className="grid gap-2"> - <Label htmlFor="edit-actualDate"> - <Calendar className="inline w-4 h-4 mr-1" /> - Actual Date - </Label> - <Input - id="edit-actualDate" - type="date" - value={formData.actualDate} - onChange={(e) => setFormData({ ...formData, actualDate: e.target.value })} - /> + <div className="grid gap-4 py-4"> + {/* 참조 정보 섹션 */} + <div className="border rounded-lg p-3 bg-gray-50"> + <Label className="text-sm font-medium text-gray-700 mb-2 block"> + 스테이지 정보 (참조용) + </Label> + + <div className="grid grid-cols-2 gap-3 text-sm"> + <div> + <Label className="text-xs text-gray-600">스테이지명</Label> + <div className="font-medium">{currentStage.stageName}</div> </div> - </div> - - <div className="grid grid-cols-2 gap-4"> - <div className="grid gap-2"> - <Label htmlFor="edit-stageStatus">Status</Label> - <Select - value={formData.stageStatus} - onValueChange={(value) => setFormData({ ...formData, stageStatus: value })} + + <div> + <Label className="text-xs text-gray-600">현재 상태</Label> + <Badge + variant={getStatusVariant(currentStage.stageStatus)} + className="text-xs" > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="PLANNED">Planned</SelectItem> - <SelectItem value="IN_PROGRESS">In Progress</SelectItem> - <SelectItem value="SUBMITTED">Submitted</SelectItem> - <SelectItem value="COMPLETED">Completed</SelectItem> - </SelectContent> - </Select> + {getStatusText(currentStage.stageStatus)} + </Badge> </div> - <div className="grid gap-2"> - <Label htmlFor="edit-priority"> - <Target className="inline w-4 h-4 mr-1" /> - Priority - </Label> - <Select - value={formData.priority} - onValueChange={(value) => setFormData({ ...formData, priority: value })} - > - <SelectTrigger> - <SelectValue /> - </SelectTrigger> - <SelectContent> - <SelectItem value="HIGH">High</SelectItem> - <SelectItem value="MEDIUM">Medium</SelectItem> - <SelectItem value="LOW">Low</SelectItem> - </SelectContent> - </Select> + + <div> + <Label className="text-xs text-gray-600">담당자</Label> + <div>{currentStage.assigneeName || "미지정"}</div> </div> + + <div> + <Label className="text-xs text-gray-600">우선순위</Label> + <div>{currentStage.priority || "MEDIUM"}</div> + </div> + + {currentStage.actualDate && ( + <div className="col-span-2"> + <Label className="text-xs text-gray-600">실제 완료일</Label> + <div className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3 text-green-500" /> + {formatDate(currentStage.actualDate)} + </div> + </div> + )} </div> + </div> + {/* 편집 가능한 필드들 */} + <div className="space-y-4"> + {/* 계획 날짜 */} <div className="grid gap-2"> - <Label htmlFor="edit-assigneeName"> - <User className="inline w-4 h-4 mr-1" /> - Assignee + <Label htmlFor="edit-planDate" className="flex items-center gap-2"> + <Calendar className="w-4 h-4" /> + 계획 날짜 + {!canEditPlanDate && ( + <Badge variant="outline" className="text-xs"> + 편집 불가 (상태: {getStatusText(currentStage.stageStatus)}) + </Badge> + )} </Label> <Input - id="edit-assigneeName" - value={formData.assigneeName} - onChange={(e) => setFormData({ ...formData, assigneeName: e.target.value })} - placeholder="Enter assignee name" + id="edit-planDate" + type="date" + value={formData.planDate} + onChange={(e) => setFormData({ ...formData, planDate: e.target.value })} + disabled={!canEditPlanDate} + className={!canEditPlanDate ? "bg-gray-100" : ""} /> + {!canEditPlanDate && ( + <p className="text-xs text-gray-500"> + 계획 날짜는 '계획' 상태일 때만 수정할 수 있습니다. + </p> + )} </div> + {/* 노트 */} <div className="grid gap-2"> - <Label htmlFor="edit-notes">Notes</Label> + <Label htmlFor="edit-notes">노트</Label> <Textarea id="edit-notes" value={formData.notes} onChange={(e) => setFormData({ ...formData, notes: e.target.value })} - placeholder="Additional notes" + placeholder="스테이지에 대한 추가 메모나 설명을 입력하세요" rows={3} /> </div> </div> </div> - <DialogFooter className="flex-shrink-0"> + <DialogFooter> <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> - Cancel + 취소 </Button> <Button onClick={handleSubmit} disabled={isLoading}> {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} - Save Changes + 저장 </Button> </DialogFooter> </DialogContent> </Dialog> ) } - // ============================================================================= // 4. Excel Import Dialog // ============================================================================= @@ -889,6 +938,13 @@ interface ExcelImportDialogProps { projectType: "ship" | "plant" } +interface ImportResult { + documents: any[] + stages: any[] + errors: string[] + warnings: string[] +} + export function ExcelImportDialog({ open, onOpenChange, @@ -896,85 +952,332 @@ export function ExcelImportDialog({ projectType }: ExcelImportDialogProps) { const [file, setFile] = React.useState<File | null>(null) - const [isUploading, setIsUploading] = React.useState(false) + const [isProcessing, setIsProcessing] = React.useState(false) + const [isDownloadingTemplate, setIsDownloadingTemplate] = React.useState(false) + const [importResult, setImportResult] = React.useState<ImportResult | null>(null) + const [processStep, setProcessStep] = React.useState<string>("") + const [progress, setProgress] = React.useState(0) + const router = useRouter() const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const selectedFile = e.target.files?.[0] if (selectedFile) { + // 파일 유효성 검사 + if (!validateFileExtension(selectedFile)) { + toast.error("Excel 파일(.xlsx, .xls)만 업로드 가능합니다.") + return + } + + if (!validateFileSize(selectedFile, 10)) { + toast.error("파일 크기는 10MB 이하여야 합니다.") + return + } + setFile(selectedFile) + setImportResult(null) } } + const validateFileExtension = (file: File): boolean => { + const allowedExtensions = ['.xlsx', '.xls'] + const fileName = file.name.toLowerCase() + return allowedExtensions.some(ext => fileName.endsWith(ext)) + } + + const validateFileSize = (file: File, maxSizeMB: number): boolean => { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes + } + + // 템플릿 다운로드 + const handleDownloadTemplate = async () => { + setIsDownloadingTemplate(true) + try { + const workbook = await createImportTemplate(projectType, contractId) + const buffer = await workbook.xlsx.writeBuffer() + + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `문서_임포트_템플릿_${projectType}_${new Date().toISOString().split('T')[0]}.xlsx` + link.click() + + window.URL.revokeObjectURL(url) + toast.success("템플릿 파일이 다운로드되었습니다.") + } catch (error) { + toast.error("템플릿 다운로드 중 오류가 발생했습니다: " + (error instanceof Error ? error.message : "알 수 없는 오류")) + } finally { + setIsDownloadingTemplate(false) + } + } + + // 엑셀 파일 처리 const handleImport = async () => { if (!file) { - toast.error("Please select a file.") + toast.error("파일을 선택해주세요.") return } - setIsUploading(true) + setIsProcessing(true) + setProgress(0) + try { - // TODO: API call to upload and process Excel file - toast.success("Excel file imported successfully.") - onOpenChange(false) - setFile(null) + setProcessStep("파일 읽는 중...") + setProgress(20) + + const workbook = new ExcelJS.Workbook() + const buffer = await file.arrayBuffer() + await workbook.xlsx.load(buffer) + + setProcessStep("데이터 검증 중...") + setProgress(40) + + // 워크시트 확인 + const documentsSheet = workbook.getWorksheet('Documents') || workbook.getWorksheet(1) + const stagesSheet = workbook.getWorksheet('Stage Plan Dates') || workbook.getWorksheet(2) + + if (!documentsSheet) { + throw new Error("Documents 시트를 찾을 수 없습니다.") + } + + setProcessStep("문서 데이터 파싱 중...") + setProgress(60) + + // 문서 데이터 파싱 + const documentData = await parseDocumentsSheet(documentsSheet, projectType) + + setProcessStep("스테이지 데이터 파싱 중...") + setProgress(80) + + // 스테이지 데이터 파싱 (선택사항) + let stageData: any[] = [] + if (stagesSheet) { + stageData = await parseStagesSheet(stagesSheet) + } + + setProcessStep("서버에 업로드 중...") + setProgress(90) + + // 서버로 데이터 전송 + const result = await uploadImportData({ + contractId, + documents: documentData.validData, + stages: stageData, + projectType + }) + + if (result.success) { + setImportResult({ + documents: documentData.validData, + stages: stageData, + errors: documentData.errors, + warnings: result.warnings || [] + }) + setProgress(100) + toast.success(`${documentData.validData.length}개 문서가 성공적으로 임포트되었습니다.`) + } else { + throw new Error(result.error || "임포트에 실패했습니다.") + } + } catch (error) { - toast.error("Error importing Excel file.") + toast.error(error instanceof Error ? error.message : "임포트 중 오류가 발생했습니다.") + setImportResult({ + documents: [], + stages: [], + errors: [error instanceof Error ? error.message : "알 수 없는 오류"], + warnings: [] + }) } finally { - setIsUploading(false) + setIsProcessing(false) + setProcessStep("") + setProgress(0) } } + const handleClose = () => { + setFile(null) + setImportResult(null) + setProgress(0) + setProcessStep("") + onOpenChange(false) + } + + const handleConfirmImport = () => { + // 페이지 새로고침하여 데이터 갱신 + router.refresh() + handleClose() + } + return ( <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-[500px]"> - <DialogHeader> + <DialogContent className="sm:max-w-[600px] max-h-[90vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> <DialogTitle> <FileSpreadsheet className="inline w-5 h-5 mr-2" /> - Import Excel File + Excel 파일 임포트 </DialogTitle> <DialogDescription> - Upload an Excel file containing document list for batch registration. + Excel 파일을 사용하여 문서를 일괄 등록합니다. </DialogDescription> </DialogHeader> - <div className="grid gap-4 py-4"> - <div className="grid gap-2"> - <Label htmlFor="excel-file">Select Excel File</Label> - <Input - id="excel-file" - type="file" - accept=".xlsx,.xls" - onChange={handleFileChange} - className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" - /> - {file && ( - <p className="text-sm text-gray-600 mt-1"> - Selected file: {file.name} + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* 템플릿 다운로드 섹션 */} + <div className="border rounded-lg p-4 bg-blue-50/30"> + <h4 className="font-medium text-blue-800 mb-2">1. 템플릿 다운로드</h4> + <p className="text-sm text-blue-700 mb-3"> + 올바른 형식과 드롭다운이 적용된 템플릿을 다운로드하세요. </p> + <Button + variant="outline" + size="sm" + onClick={handleDownloadTemplate} + disabled={isDownloadingTemplate} + > + {isDownloadingTemplate ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Download className="h-4 w-4 mr-2" /> + )} + 템플릿 다운로드 + </Button> + </div> + + {/* 파일 업로드 섹션 */} + <div className="border rounded-lg p-4"> + <h4 className="font-medium mb-2">2. 파일 업로드</h4> + <div className="grid gap-2"> + <Label htmlFor="excel-file">Excel 파일 선택</Label> + <Input + id="excel-file" + type="file" + accept=".xlsx,.xls" + onChange={handleFileChange} + disabled={isProcessing} + className="file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100" + /> + {file && ( + <p className="text-sm text-gray-600 mt-1"> + 선택된 파일: {file.name} ({(file.size / 1024 / 1024).toFixed(2)} MB) + </p> + )} + </div> + </div> + + {/* 진행 상태 */} + {isProcessing && ( + <div className="border rounded-lg p-4 bg-yellow-50/30"> + <div className="flex items-center gap-2 mb-2"> + <Loader2 className="h-4 w-4 animate-spin text-yellow-600" /> + <span className="text-sm font-medium text-yellow-800">처리 중...</span> + </div> + <p className="text-sm text-yellow-700 mb-2">{processStep}</p> + <Progress value={progress} className="h-2" /> + </div> )} - </div> - <div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> - <h4 className="font-medium text-blue-800 mb-2">File Format Guide</h4> - <div className="text-sm text-blue-700 space-y-1"> - <p>• First row must be header row</p> - <p>• Required columns: Document Number, Document Title, Document Class</p> - {projectType === "plant" && ( - <p>• Optional columns: Vendor Document Number</p> - )} - <p>• Supported formats: .xlsx, .xls</p> + {/* 임포트 결과 */} + {importResult && ( + <div className="space-y-3"> + {importResult.documents.length > 0 && ( + <Alert> + <CheckCircle className="h-4 w-4" /> + <AlertDescription> + <strong>{importResult.documents.length}개</strong> 문서가 성공적으로 임포트되었습니다. + {importResult.stages.length > 0 && ( + <> ({importResult.stages.length}개 스테이지 계획날짜 포함)</> + )} + </AlertDescription> + </Alert> + )} + + {importResult.warnings.length > 0 && ( + <Alert> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>경고:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.warnings.map((warning, index) => ( + <li key={index} className="text-sm">{warning}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + + {importResult.errors.length > 0 && ( + <Alert variant="destructive"> + <AlertCircle className="h-4 w-4" /> + <AlertDescription> + <strong>오류:</strong> + <ul className="mt-1 list-disc list-inside"> + {importResult.errors.map((error, index) => ( + <li key={index} className="text-sm">{error}</li> + ))} + </ul> + </AlertDescription> + </Alert> + )} + </div> + )} + + {/* 파일 형식 가이드 */} + <div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> + <h4 className="font-medium text-gray-800 mb-2">파일 형식 가이드</h4> + <div className="text-sm text-gray-700 space-y-1"> + <p><strong>Documents 시트:</strong></p> + <ul className="ml-4 list-disc"> + <li>Document Number* (문서번호)</li> + <li>Document Name* (문서명)</li> + <li>Document Class* (문서클래스 - 드롭다운 선택)</li> + {projectType === "plant" && ( + <li>Vendor Doc No. (벤더문서번호)</li> + )} + </ul> + <p className="mt-2"><strong>Stage Plan Dates 시트 (선택사항):</strong></p> + <ul className="ml-4 list-disc"> + <li>Document Number* (문서번호)</li> + <li>Stage Name* (스테이지명 - 드롭다운 선택, 해당 문서클래스에 맞는 스테이지만 선택)</li> + <li>Plan Date (계획날짜: YYYY-MM-DD)</li> + </ul> + <p className="mt-2 text-green-600"><strong>스마트 기능:</strong></p> + <ul className="ml-4 list-disc text-green-600"> + <li>Document Class는 드롭다운으로 정확한 값만 선택 가능</li> + <li>Stage Name도 드롭다운으로 오타 방지</li> + <li>"사용 가이드" 시트에서 각 클래스별 사용 가능한 스테이지 확인 가능</li> + </ul> + <p className="mt-2 text-red-600">* 필수 항목</p> + </div> </div> </div> </div> - <DialogFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> - Cancel - </Button> - <Button onClick={handleImport} disabled={!file || isUploading}> - {isUploading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} - {isUploading ? "Importing..." : "Import"} + <DialogFooter className="flex-shrink-0"> + <Button variant="outline" onClick={handleClose}> + {importResult ? "닫기" : "취소"} </Button> + {!importResult ? ( + <Button + onClick={handleImport} + disabled={!file || isProcessing} + > + {isProcessing ? ( + <Loader2 className="h-4 w-4 animate-spin mr-2" /> + ) : ( + <Upload className="h-4 w-4 mr-2" /> + )} + {isProcessing ? "처리 중..." : "임포트 시작"} + </Button> + ) : importResult.documents.length > 0 ? ( + <Button onClick={handleConfirmImport}> + 완료 및 새로고침 + </Button> + ) : null} </DialogFooter> </DialogContent> </Dialog> @@ -1105,3 +1408,224 @@ export function DeleteDocumentsDialog({ </Drawer> ) } + +// ============================================================================= +// Helper Functions for Excel Import +// ============================================================================= + +// ExcelJS 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...) +function getExcelColumnName(index: number): string { + let result = "" + while (index > 0) { + index-- + result = String.fromCharCode(65 + (index % 26)) + result + index = Math.floor(index / 26) + } + return result +} + +// 헤더 행 스타일링 함수 +function styleHeaderRow(headerRow: ExcelJS.Row, bgColor: string = 'FF4472C4') { + headerRow.eachCell((cell) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: bgColor } + } + cell.font = { + color: { argb: 'FFFFFFFF' }, + bold: true + } + cell.alignment = { + horizontal: 'center', + vertical: 'middle' + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + if (String(cell.value).includes('*')) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE74C3C' } + } + } + }) +} + +// 템플릿 생성 함수 +async function createImportTemplate(projectType: "ship" | "plant", contractId: number) { + const res = await getDocumentClassOptionsByContract(contractId); + if (!res.success) throw new Error(res.error || "데이터 로딩 실패"); + + const documentClasses = res.data.classes; // [{id, code, description}] + const options = res.data.options; // [{documentClassId, optionValue, ...}] + + // 클래스별 옵션 맵 + const optionsByClassId = new Map<number, string[]>(); + for (const c of documentClasses) optionsByClassId.set(c.id, []); + for (const o of options) { + optionsByClassId.get(o.documentClassId)?.push(o.optionValue); + } + + // 모든 스테이지 명 (유니크) + const allStageNames = Array.from(new Set(options.map(o => o.optionValue))); + + const workbook = new ExcelJS.Workbook(); + + // ================= ReferenceData (hidden) ================= + const referenceSheet = workbook.addWorksheet("ReferenceData", { state: "hidden" }); + + // A열: DocumentClasses + referenceSheet.getCell("A1").value = "DocumentClasses"; + documentClasses.forEach((docClass, i) => { + referenceSheet.getCell(`A${i + 2}`).value = `${docClass.description}`; + }); + + // B열부터: 각 클래스의 Stage 옵션 + let currentCol = 2; // B + for (const docClass of documentClasses) { + const colLetter = getExcelColumnName(currentCol); + referenceSheet.getCell(`${colLetter}1`).value = docClass.description; + + const list = optionsByClassId.get(docClass.id) ?? []; + list.forEach((v, i) => { + referenceSheet.getCell(`${colLetter}${i + 2}`).value = v; + }); + + currentCol++; + } + + // 마지막 열: AllStageNames + const allStagesCol = getExcelColumnName(currentCol); + referenceSheet.getCell(`${allStagesCol}1`).value = "AllStageNames"; + allStageNames.forEach((v, i) => { + referenceSheet.getCell(`${allStagesCol}${i + 2}`).value = v; + }); + + // ================= Documents ================= + const documentsSheet = workbook.addWorksheet("Documents"); + const documentHeaders = [ + "Document Number*", + "Document Name*", + "Document Class*", + ...(projectType === "plant" ? ["Vendor Doc No."] : []), + "Notes", + ]; + const documentHeaderRow = documentsSheet.addRow(documentHeaders); + styleHeaderRow(documentHeaderRow); + + const sampleDocumentData = + projectType === "ship" + ? [ + "SH-2024-001", + "기본 설계 도면", + documentClasses[0] + ? `${documentClasses[0].description}` + : "", + "참고사항", + ] + : [ + "PL-2024-001", + "공정 설계 도면", + documentClasses[0] + ? `${documentClasses[0].description}` + : "", + "V-001", + "참고사항", + ]; + + documentsSheet.addRow(sampleDocumentData); + + // Document Class 드롭다운 + const docClassColIndex = 3; // C + const docClassCol = getExcelColumnName(docClassColIndex); + documentsSheet.dataValidations.add(`${docClassCol}2:${docClassCol}1000`, { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$A$2:$A$${documentClasses.length + 1}`], + }); + + documentsSheet.columns = [ + { width: 15 }, + { width: 25 }, + { width: 28 }, + ...(projectType === "plant" ? [{ width: 18 }] : []), + { width: 24 }, + ]; + + // ================= Stage Plan Dates ================= + const stagesSheet = workbook.addWorksheet("Stage Plan Dates"); + const stageHeaderRow = stagesSheet.addRow(["Document Number*", "Stage Name*", "Plan Date"]); + styleHeaderRow(stageHeaderRow, "FF27AE60"); + + const firstClass = documentClasses[0]; + const firstClassOpts = firstClass ? optionsByClassId.get(firstClass.id) ?? [] : []; + + const sampleStageData = [ + [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[0] ?? "", "2024-02-15"], + [projectType === "ship" ? "SH-2024-001" : "PL-2024-001", firstClassOpts[1] ?? "", "2024-03-01"], + ]; + + sampleStageData.forEach(row => { + const r = stagesSheet.addRow(row); + r.getCell(3).numFmt = "yyyy-mm-dd"; + }); + + // Stage Name 드롭다운 (전체) + stagesSheet.dataValidations.add("B2:B1000", { + type: "list", + allowBlank: false, + formulae: [`ReferenceData!$${allStagesCol}$2:$${allStagesCol}$${allStageNames.length + 1}`], + promptTitle: "Stage Name 선택", + prompt: "Document의 Document Class에 해당하는 Stage Name을 선택하세요.", + }); + + stagesSheet.columns = [{ width: 15 }, { width: 30 }, { width: 12 }]; + + // ================= 사용 가이드 ================= + const guideSheet = workbook.addWorksheet("사용 가이드"); + const guideContent: (string[])[] = [ + ["문서 임포트 가이드"], + [""], + ["1. Documents 시트"], + [" - Document Number*: 고유한 문서 번호를 입력하세요"], + [" - Document Name*: 문서명을 입력하세요"], + [" - Document Class*: 드롭다운에서 문서 클래스를 선택하세요"], + [" - Vendor Doc No.: 벤더 문서 번호"], + [" - Notes: 참고사항"], + [""], + ["2. Stage Plan Dates 시트 (선택사항)"], + [" - Document Number*: Documents 시트의 Document Number와 일치해야 합니다"], + [" - Stage Name*: 드롭다운에서 해당 문서 클래스에 맞는 스테이지명을 선택하세요"], + [" - Plan Date: 계획 날짜 (YYYY-MM-DD 형식)"], + [""], + ["3. 주의사항"], + [" - * 표시는 필수 항목입니다"], + [" - Document Number는 고유해야 합니다"], + [" - Stage Name은 해당 Document의 Document Class에 속한 것만 유효합니다"], + [" - 날짜는 YYYY-MM-DD 형식으로 입력하세요"], + [""], + ["4. Document Class별 사용 가능한 Stage Names"], + [""], + ]; + + for (const c of documentClasses) { + guideContent.push([`${c.code} - ${c.description}:`]); + (optionsByClassId.get(c.id) ?? []).forEach(v => guideContent.push([` • ${v}`])); + guideContent.push([""]); + } + + guideContent.forEach((row, i) => { + const r = guideSheet.addRow(row); + if (i === 0) r.getCell(1).font = { bold: true, size: 14 }; + else if (row[0] && row[0].includes(":") && !row[0].startsWith(" ")) r.getCell(1).font = { bold: true }; + }); + guideSheet.getColumn(1).width = 60; + + return workbook; +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx index 070d6904..eda6f57a 100644 --- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -1,3 +1,4 @@ + "use client" import React from "react" @@ -6,14 +7,37 @@ import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Progress } from "@/components/ui/progress" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" import { Calendar, CheckCircle, Edit, - FileText + FileText, + Loader2, + Target, + User } from "lucide-react" import { formatDate } from "@/lib/utils" import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { updateStage } from "./document-stages-service" interface DocumentStagesExpandedContentProps { document: DocumentStagesOnlyView @@ -74,35 +98,51 @@ export function DocumentStagesExpandedContent({ : "bg-white border-gray-200" )} > - {/* 스테이지 순서 */} - <div className="absolute -top-1 -left-1 bg-gray-600 text-white rounded-full w-4 h-4 flex items-center justify-center text-xs font-medium"> - {index + 1} - </div> + {/* 편집 버튼 - 우측 상단에 절대 위치 */} + <Button + size="sm" + variant="ghost" + onClick={() => onEditStage(stage.id)} + className="absolute top-1 right-1 h-5 w-5 p-0 hover:bg-gray-100" + > + <Edit className="h-3 w-3" /> + </Button> - {/* 스테이지명 */} - <div className="mb-2 pr-6"> - <div className="font-medium text-sm truncate" title={stage.stageName}> + {/* 상단: 스테이지 순서, 스테이지명, 상태를 한 줄에 배치 */} + <div className="flex items-center gap-2 mb-2 pr-6"> + {/* 스테이지 순서 */} + <div + className="text-white rounded-full min-w-[20px] w-4 h-4 flex items-center justify-center text-xs font-bold flex-shrink-0" + style={{ + backgroundColor: '#4B5563', + borderRadius: '50%' + }} + > + {index + 1} + </div> + {/* 스테이지명 */} + <div className="font-medium text-sm truncate flex-1" title={stage.stageName}> {stage.stageName} </div> - {isCurrentStage && ( - <Badge variant="default" className="text-xs px-1 py-0 mt-1"> - 현재 + + {/* 상태 배지 */} + <div className="flex items-center gap-1 flex-shrink-0"> + <Badge + variant={getStatusVariant(stage.stageStatus)} + className="text-xs px-1.5 py-0" + > + {getStatusText(stage.stageStatus)} </Badge> - )} - </div> - - {/* 상태 */} - <div className="mb-2"> - <Badge - variant={getStatusVariant(stage.stageStatus)} - className="text-xs px-1.5 py-0" - > - {getStatusText(stage.stageStatus)} - </Badge> + {isCurrentStage && ( + <Badge variant="default" className="text-xs px-1 py-0"> + 현재 + </Badge> + )} + </div> </div> {/* 날짜 정보 */} - <div className="space-y-1 text-xs text-gray-600 mb-2"> + <div className="space-y-1 text-xs text-gray-600"> {planDate && ( <div className="flex items-center gap-1"> <Calendar className="h-3 w-3" /> @@ -116,16 +156,6 @@ export function DocumentStagesExpandedContent({ </div> )} </div> - - {/* 편집 버튼 */} - <Button - size="sm" - variant="ghost" - onClick={() => onEditStage(stage.id)} - className="absolute top-1 right-1 h-5 w-5 p-0 hover:bg-gray-100" - > - <Edit className="h-3 w-3" /> - </Button> </div> ) })} @@ -133,4 +163,4 @@ export function DocumentStagesExpandedContent({ )} </div> ) -}
\ No newline at end of file +} diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 108b5869..1e60a062 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -5,7 +5,7 @@ import { revalidatePath, revalidateTag } from "next/cache" import { redirect } from "next/navigation" import db from "@/db/db" import { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages } from "@/db/schema" -import { and, eq, asc, desc, sql, inArray, max, ne ,or, ilike} from "drizzle-orm" +import { and, eq, asc, desc, sql, inArray, max, ne, or, ilike } from "drizzle-orm" import { createDocumentSchema, updateDocumentSchema, @@ -72,7 +72,7 @@ export async function updateDocument(data: UpdateDocumentData) { // 3. 캐시 무효화 revalidatePath(`/contracts/${updatedDocument.contractId}/documents`) - + return { success: true, data: updatedDocument @@ -85,28 +85,28 @@ export async function updateDocument(data: UpdateDocumentData) { // 문서 삭제 (소프트 삭제) export async function deleteDocument(input: { id: number }) { noStore() - + try { const validatedData = deleteDocumentSchema.parse(input) - + // 문서 존재 확인 const existingDoc = await db.query.documents.findFirst({ where: eq(documents.id, validatedData.id) }) - + if (!existingDoc) { throw new Error("문서를 찾을 수 없습니다") } - + // 연관된 스테이지 확인 const relatedStages = await db.query.issueStages.findMany({ where: eq(issueStages.documentId, validatedData.id) }) - + if (relatedStages.length > 0) { throw new Error("연관된 스테이지가 있는 문서는 삭제할 수 없습니다. 먼저 스테이지를 삭제해주세요.") } - + // 소프트 삭제 (상태 변경) await db .update(documents) @@ -115,16 +115,16 @@ export async function deleteDocument(input: { id: number }) { updatedAt: new Date(), }) .where(eq(documents.id, validatedData.id)) - + // 캐시 무효화 revalidateTag(`documents-${existingDoc.contractId}`) revalidatePath(`/contracts/${existingDoc.contractId}/documents`) - + return { success: true, message: "문서가 성공적으로 삭제되었습니다" } - + } catch (error) { console.error("Error deleting document:", error) return { @@ -217,19 +217,19 @@ export async function deleteDocuments(data: DeleteDocumentsData) { // 스테이지 생성 export async function createStage(input: CreateStageInput) { noStore() - + try { const validatedData = createStageSchema.parse(input) - + // 문서 존재 확인 const document = await db.query.documents.findFirst({ where: eq(documents.id, validatedData.documentId) }) - + if (!document) { throw new Error("문서를 찾을 수 없습니다") } - + // 스테이지명 중복 검사 const existingStage = await db.query.issueStages.findFirst({ where: and( @@ -237,11 +237,11 @@ export async function createStage(input: CreateStageInput) { eq(issueStages.stageName, validatedData.stageName) ) }) - + if (existingStage) { throw new Error("이미 존재하는 스테이지명입니다") } - + // 스테이지 순서 자동 설정 (제공되지 않은 경우) let stageOrder = validatedData.stageOrder if (stageOrder === 0 || stageOrder === undefined) { @@ -249,10 +249,10 @@ export async function createStage(input: CreateStageInput) { .select({ maxOrder: max(issueStages.stageOrder) }) .from(issueStages) .where(eq(issueStages.documentId, validatedData.documentId)) - + stageOrder = (maxOrderResult[0]?.maxOrder ?? -1) + 1 } - + // 스테이지 생성 const [newStage] = await db.insert(issueStages).values({ documentId: validatedData.documentId, @@ -269,18 +269,18 @@ export async function createStage(input: CreateStageInput) { createdAt: new Date(), updatedAt: new Date(), }).returning() - + // 캐시 무효화 revalidateTag(`documents-${document.contractId}`) revalidateTag(`document-${validatedData.documentId}`) revalidatePath(`/contracts/${document.contractId}/documents`) - + return { success: true, data: newStage, message: "스테이지가 성공적으로 생성되었습니다" } - + } catch (error) { console.error("Error creating stage:", error) return { @@ -293,10 +293,10 @@ export async function createStage(input: CreateStageInput) { // 스테이지 수정 export async function updateStage(input: UpdateStageInput) { noStore() - + try { const validatedData = updateStageSchema.parse(input) - + // 스테이지 존재 확인 const existingStage = await db.query.issueStages.findFirst({ where: eq(issueStages.id, validatedData.id), @@ -304,11 +304,11 @@ export async function updateStage(input: UpdateStageInput) { document: true } }) - + if (!existingStage) { throw new Error("스테이지를 찾을 수 없습니다") } - + // 스테이지명 중복 검사 (스테이지명 변경 시) if (validatedData.stageName && validatedData.stageName !== existingStage.stageName) { const duplicateStage = await db.query.issueStages.findFirst({ @@ -317,12 +317,12 @@ export async function updateStage(input: UpdateStageInput) { eq(issueStages.stageName, validatedData.stageName) ) }) - + if (duplicateStage) { throw new Error("이미 존재하는 스테이지명입니다") } } - + // 스테이지 업데이트 const [updatedStage] = await db .update(issueStages) @@ -332,18 +332,18 @@ export async function updateStage(input: UpdateStageInput) { }) .where(eq(issueStages.id, validatedData.id)) .returning() - + // 캐시 무효화 revalidateTag(`documents-${existingStage.document.contractId}`) revalidateTag(`document-${existingStage.documentId}`) revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) - + return { success: true, data: updatedStage, message: "스테이지가 성공적으로 수정되었습니다" } - + } catch (error) { console.error("Error updating stage:", error) return { @@ -356,10 +356,10 @@ export async function updateStage(input: UpdateStageInput) { // 스테이지 삭제 export async function deleteStage(input: { id: number }) { noStore() - + try { const validatedData = deleteStageSchema.parse(input) - + // 스테이지 존재 확인 const existingStage = await db.query.issueStages.findFirst({ where: eq(issueStages.id, validatedData.id), @@ -367,29 +367,29 @@ export async function deleteStage(input: { id: number }) { document: true } }) - + if (!existingStage) { throw new Error("스테이지를 찾을 수 없습니다") } - + // 연관된 리비전 확인 (향후 구현 시) // const relatedRevisions = await db.query.revisions.findMany({ // where: eq(revisions.issueStageId, validatedData.id) // }) - + // if (relatedRevisions.length > 0) { // throw new Error("연관된 리비전이 있는 스테이지는 삭제할 수 없습니다") // } - + // 스테이지 삭제 await db.delete(issueStages).where(eq(issueStages.id, validatedData.id)) - + // 스테이지 순서 재정렬 const remainingStages = await db.query.issueStages.findMany({ where: eq(issueStages.documentId, existingStage.documentId), orderBy: [issueStages.stageOrder] }) - + for (let i = 0; i < remainingStages.length; i++) { if (remainingStages[i].stageOrder !== i) { await db @@ -398,17 +398,17 @@ export async function deleteStage(input: { id: number }) { .where(eq(issueStages.id, remainingStages[i].id)) } } - + // 캐시 무효화 revalidateTag(`documents-${existingStage.document.contractId}`) revalidateTag(`document-${existingStage.documentId}`) revalidatePath(`/contracts/${existingStage.document.contractId}/documents`) - + return { success: true, message: "스테이지가 성공적으로 삭제되었습니다" } - + } catch (error) { console.error("Error deleting stage:", error) return { @@ -421,22 +421,22 @@ export async function deleteStage(input: { id: number }) { // 스테이지 순서 변경 export async function reorderStages(input: any) { noStore() - + try { const validatedData = reorderStagesSchema.parse(input) - + // 스테이지 순서 유효성 검사 validateStageOrder(validatedData.stages) - + // 문서 존재 확인 const document = await db.query.documents.findFirst({ where: eq(documents.id, validatedData.documentId) }) - + if (!document) { throw new Error("문서를 찾을 수 없습니다") } - + // 스테이지들이 해당 문서에 속하는지 확인 const stageIds = validatedData.stages.map(s => s.id) const existingStages = await db.query.issueStages.findMany({ @@ -445,34 +445,34 @@ export async function reorderStages(input: any) { inArray(issueStages.id, stageIds) ) }) - + if (existingStages.length !== validatedData.stages.length) { throw new Error("일부 스테이지가 해당 문서에 속하지 않습니다") } - + // 트랜잭션으로 순서 업데이트 await db.transaction(async (tx) => { for (const stage of validatedData.stages) { await tx .update(issueStages) - .set({ + .set({ stageOrder: stage.stageOrder, updatedAt: new Date() }) .where(eq(issueStages.id, stage.id)) } }) - + // 캐시 무효화 revalidateTag(`documents-${document.contractId}`) revalidateTag(`document-${validatedData.documentId}`) revalidatePath(`/contracts/${document.contractId}/documents`) - + return { success: true, message: "스테이지 순서가 성공적으로 변경되었습니다" } - + } catch (error) { console.error("Error reordering stages:", error) return { @@ -489,10 +489,10 @@ export async function reorderStages(input: any) { // 일괄 문서 생성 (엑셀 임포트) export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult> { noStore() - + try { const validatedData = bulkCreateDocumentsSchema.parse(input) - + const result: ExcelImportResult = { totalRows: validatedData.documents.length, successCount: 0, @@ -500,12 +500,12 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult errors: [], createdDocuments: [] } - + // 트랜잭션으로 일괄 처리 await db.transaction(async (tx) => { for (let i = 0; i < validatedData.documents.length; i++) { const docData = validatedData.documents[i] - + try { // 문서번호 중복 검사 const existingDoc = await tx.query.documents.findFirst({ @@ -515,7 +515,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult eq(documents.status, "ACTIVE") ) }) - + if (existingDoc) { result.errors.push({ row: i + 2, // 엑셀 행 번호 (헤더 포함) @@ -525,7 +525,7 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult result.failureCount++ continue } - + // 문서 생성 const [newDoc] = await tx.insert(documents).values({ contractId: validatedData.contractId, @@ -545,14 +545,14 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult createdAt: new Date(), updatedAt: new Date(), }).returning() - + result.createdDocuments.push({ id: newDoc.id, docNumber: newDoc.docNumber, title: newDoc.title }) result.successCount++ - + } catch (error) { result.errors.push({ row: i + 2, @@ -562,13 +562,13 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult } } }) - + // 캐시 무효화 revalidateTag(`documents-${validatedData.contractId}`) revalidatePath(`/contracts/${validatedData.contractId}/documents`) - + return result - + } catch (error) { console.error("Error bulk creating documents:", error) throw new Error("일괄 문서 생성 중 오류가 발생했습니다") @@ -578,20 +578,20 @@ export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult // 일괄 상태 업데이트 export async function bulkUpdateStageStatus(input: any) { noStore() - + try { const validatedData = bulkUpdateStatusSchema.parse(input) - + // 스테이지들 존재 확인 const existingStages = await db.query.issueStages.findMany({ where: inArray(issueStages.id, validatedData.stageIds), with: { document: true } }) - + if (existingStages.length !== validatedData.stageIds.length) { throw new Error("일부 스테이지를 찾을 수 없습니다") } - + // 일괄 업데이트 await db .update(issueStages) @@ -601,19 +601,19 @@ export async function bulkUpdateStageStatus(input: any) { updatedAt: new Date() }) .where(inArray(issueStages.id, validatedData.stageIds)) - + // 관련된 계약들의 캐시 무효화 const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] for (const contractId of contractIds) { revalidateTag(`documents-${contractId}`) revalidatePath(`/contracts/${contractId}/documents`) } - + return { success: true, message: `${validatedData.stageIds.length}개 스테이지의 상태가 업데이트되었습니다` } - + } catch (error) { console.error("Error bulk updating stage status:", error) return { @@ -626,20 +626,20 @@ export async function bulkUpdateStageStatus(input: any) { // 일괄 담당자 지정 export async function bulkAssignStages(input: any) { noStore() - + try { const validatedData = bulkAssignSchema.parse(input) - + // 스테이지들 존재 확인 const existingStages = await db.query.issueStages.findMany({ where: inArray(issueStages.id, validatedData.stageIds), with: { document: true } }) - + if (existingStages.length !== validatedData.stageIds.length) { throw new Error("일부 스테이지를 찾을 수 없습니다") } - + // 일괄 담당자 지정 await db .update(issueStages) @@ -649,19 +649,19 @@ export async function bulkAssignStages(input: any) { updatedAt: new Date() }) .where(inArray(issueStages.id, validatedData.stageIds)) - + // 관련된 계약들의 캐시 무효화 const contractIds = [...new Set(existingStages.map(s => s.document.contractId))] for (const contractId of contractIds) { revalidateTag(`documents-${contractId}`) revalidatePath(`/contracts/${contractId}/documents`) } - + return { success: true, message: `${validatedData.stageIds.length}개 스테이지에 담당자가 지정되었습니다` } - + } catch (error) { console.error("Error bulk assigning stages:", error) return { @@ -673,14 +673,25 @@ export async function bulkAssignStages(input: any) { // 문서번호 타입 목록 조회 -export async function getDocumentNumberTypes() { +export async function getDocumentNumberTypes(contractId: number) { try { + + const project = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!project) { + return { + success: false, + } + } + const types = await db .select() .from(documentNumberTypes) - .where(eq(documentNumberTypes.isActive, true)) + .where(and (eq(documentNumberTypes.isActive, true), eq(documentNumberTypes.projectId,project.projectId))) .orderBy(asc(documentNumberTypes.name)) - + return { success: true, data: types } } catch (error) { console.error("문서번호 타입 조회 실패:", error) @@ -691,6 +702,7 @@ export async function getDocumentNumberTypes() { // 문서번호 타입 설정 조회 export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) { try { + const configs = await db .select({ id: documentNumberTypeConfigs.id, @@ -698,32 +710,31 @@ export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) description: documentNumberTypeConfigs.description, remark: documentNumberTypeConfigs.remark, codeGroupId: documentNumberTypeConfigs.codeGroupId, - documentClassId: documentNumberTypeConfigs.documentClassId, + // documentClassId: documentNumberTypeConfigs.documentClassId, codeGroup: { id: codeGroups.id, groupId: codeGroups.groupId, description: codeGroups.description, controlType: codeGroups.controlType, }, - documentClass: { - id: documentClasses.id, - code: documentClasses.code, - description: documentClasses.description, - } + // documentClass: { + // id: documentClasses.id, + // code: documentClasses.code, + // description: documentClasses.description, + // } }) .from(documentNumberTypeConfigs) .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) - .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) + // .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) .where( and( eq(documentNumberTypeConfigs.documentNumberTypeId, documentNumberTypeId), - eq(documentNumberTypeConfigs.isActive, true) + eq(documentNumberTypeConfigs.isActive, true), + // eq(documentNumberTypeConfigs.projectId, project.projectId) ) ) .orderBy(asc(documentNumberTypeConfigs.sdq)) - console.log(configs,"configs") - return { success: true, data: configs } } catch (error) { console.error("문서번호 타입 설정 조회 실패:", error) @@ -733,8 +744,9 @@ export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) // 콤보박스 옵션 조회 export async function getComboBoxOptions(codeGroupId: number) { - console.log(codeGroupId,"codeGroupId") try { + + const settings = await db .select({ id: comboBoxSettings.id, @@ -746,8 +758,6 @@ export async function getComboBoxOptions(codeGroupId: number) { .where(eq(comboBoxSettings.codeGroupId, codeGroupId)) .orderBy(asc(comboBoxSettings.code)) - console.log("settings",settings) - return { success: true, data: settings } } catch (error) { console.error("콤보박스 옵션 조회 실패:", error) @@ -756,14 +766,29 @@ export async function getComboBoxOptions(codeGroupId: number) { } // 문서 클래스 목록 조회 -export async function getDocumentClasses() { +export async function getDocumentClasses(contractId:number) { try { + const projectId = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + }); + + if (!projectId) { + return { + success: false, + } + } + const classes = await db .select() .from(documentClasses) - .where(eq(documentClasses.isActive, true)) + .where( + and( + eq(documentClasses.isActive, true), + eq(documentClasses.projectId, projectId.projectId) + ) + ) .orderBy(asc(documentClasses.description)) - + return { success: true, data: classes } } catch (error) { console.error("문서 클래스 조회 실패:", error) @@ -783,8 +808,8 @@ export async function getDocumentClassOptions(documentClassId: number) { eq(documentClassOptions.isActive, true) ) ) - .orderBy(asc(documentClassOptions.sortOrder)) - + // .orderBy(asc(documentClassOptions.sortOrder)) + return { success: true, data: options } } catch (error) { console.error("문서 클래스 옵션 조회 실패:", error) @@ -792,10 +817,68 @@ export async function getDocumentClassOptions(documentClassId: number) { } } +export async function getDocumentClassOptionsByContract(contractId: number) { + try { + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractId), + columns: { projectId: true }, + }); + + if (!contract) { + return { success: false, error: "계약을 찾을 수 없습니다." as const }; + } + // 프로젝트의 활성 클래스들 + const classes = await db + .select({ + id: documentClasses.id, + code: documentClasses.code, + description: documentClasses.value, + }) + .from(documentClasses) + .where( + and( + eq(documentClasses.isActive, true), + eq(documentClasses.projectId, contract.projectId) + ) + ) + .orderBy(asc(documentClasses.value)); + + if (classes.length === 0) { + return { success: true, data: { classes: [], options: [] as any[] } }; + } + + // 해당 클래스들의 모든 옵션 한 방에 + const classIds = classes.map(c => c.id); + + + const options = await db + .select({ + id: documentClassOptions.id, + documentClassId: documentClassOptions.documentClassId, + optionValue: documentClassOptions.description, + // sortOrder: documentClassOptions.sortOrder, + }) + .from(documentClassOptions) + .where( + and( + inArray(documentClassOptions.documentClassId, classIds), + eq(documentClassOptions.isActive, true) + ) + ); + // 필요하면 .orderBy(asc(documentClassOptions.sortOrder ?? documentClassOptions.optionValue)) + + return { success: true, data: { classes, options } }; + } catch (error) { + console.log(error) + console.error("문서 클래스/옵션 일괄 조회 실패:", error); + return { success: false, error: "문서 클래스/옵션을 불러올 수 없습니다." as const }; + } +} + // 문서번호 생성 export async function generateDocumentNumber(configs: any[], values: Record<string, string>) { let docNumber = "" - + configs.forEach((config) => { const value = values[`field_${config.sdq}`] || "" if (value) { @@ -806,7 +889,7 @@ export async function generateDocumentNumber(configs: any[], values: Record<stri } } }) - + return docNumber.replace(/-$/, "") // 마지막 하이픈 제거 } @@ -843,6 +926,9 @@ export async function createDocument(data: CreateDocumentData) { const configsResult = await getDocumentNumberTypeConfigs( data.documentNumberTypeId ) + + console.log(configsResult, "configsResult") + if (!configsResult.success) { return { success: false, error: configsResult.error } } @@ -860,10 +946,10 @@ export async function createDocument(data: CreateDocumentData) { updatedAt: new Date(), // 선택 - pic: data.pic ?? null, - vendorDocNumber: data.vendorDocNumber ?? null, + pic: data.pic ?? null, + vendorDocNumber: data.vendorDocNumber ?? null, - } + } const [document] = await db .insert(documents) @@ -892,13 +978,13 @@ export async function createDocument(data: CreateDocumentData) { if (stageOptionsResult.success && stageOptionsResult.data.length > 0) { const now = new Date() const stageInserts = stageOptionsResult.data.map((opt, idx) => ({ - documentId: document.id, - stageName: opt.optionValue, - stageOrder: opt.sortOrder ?? idx + 1, + documentId: document.id, + stageName: opt.optionValue, + stageOrder: opt.sortOrder ?? idx + 1, stageStatus: "PLANNED" as const, - planDate: data.planDates[opt.id] ?? null, - createdAt: now, - updatedAt: now, + planDate: data.planDates[opt.id] ?? null, + createdAt: now, + updatedAt: now, })) await db.insert(issueStages).values(stageInserts) } @@ -955,10 +1041,10 @@ export async function getDocumentStagesOnly( // 정렬 처리 const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => - item.desc - ? desc(documentStagesOnlyView[item.id]) - : asc(documentStagesOnlyView[item.id]) - ) + item.desc + ? desc(documentStagesOnlyView[item.id]) + : asc(documentStagesOnlyView[item.id]) + ) : [desc(documentStagesOnlyView.createdAt)] // 트랜잭션 실행 diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 7d41277e..f843862d 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -182,16 +182,6 @@ export function DocumentStagesTable({ // 필터 필드 정의 const filterFields: DataTableFilterField<DocumentStagesOnlyView>[] = [ - { - label: "문서번호", - value: "docNumber", - placeholder: "문서번호로 검색...", - }, - { - label: "제목", - value: "title", - placeholder: "제목으로 검색...", - }, ] const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesOnlyView>[] = [ |
