diff options
Diffstat (limited to 'lib/vendor-document-list/plant')
8 files changed, 4119 insertions, 0 deletions
diff --git a/lib/vendor-document-list/plant/document-stage-actions.ts b/lib/vendor-document-list/plant/document-stage-actions.ts new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/lib/vendor-document-list/plant/document-stage-actions.ts diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx new file mode 100644 index 00000000..732a4bed --- /dev/null +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -0,0 +1,789 @@ +"use client" + +import React from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Button } from "@/components/ui/button" +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 { Badge } from "@/components/ui/badge" +import { DocumentStagesOnlyView } from "@/db/schema" +import { Upload, FileSpreadsheet, Calendar, User, Target, Loader2 } from "lucide-react" +import { toast } from "sonner" +import { + getDocumentNumberTypes, + getDocumentNumberTypeConfigs, + getComboBoxOptions, + getDocumentClasses, + createDocument, + updateStage +} from "./document-stages-service" + +// ============================================================================= +// 1. Add Document Dialog (Updated with fixed header/footer and English text) +// ============================================================================= +interface AddDocumentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +export function AddDocumentDialog({ + open, + onOpenChange, + contractId, + projectType +}: AddDocumentDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) + const [documentNumberTypes, setDocumentNumberTypes] = React.useState<any[]>([]) + const [documentClasses, setDocumentClasses] = React.useState<any[]>([]) + const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([]) + const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) + + const [formData, setFormData] = React.useState({ + documentNumberTypeId: "", + documentClassId: "", + title: "", + pic: "", + vendorDocNumber: "", + fieldValues: {} as Record<string, string> + }) + + // Load initial data + React.useEffect(() => { + if (open) { + loadInitialData() + } + }, [open]) + + const loadInitialData = async () => { + setIsLoading(true) + try { + const [typesResult, classesResult] = await Promise.all([ + getDocumentNumberTypes(), + getDocumentClasses() + ]) + + if (typesResult.success) { + setDocumentNumberTypes(typesResult.data) + } + if (classesResult.success) { + setDocumentClasses(classesResult.data) + } + } catch (error) { + toast.error("Error loading data.") + } finally { + setIsLoading(false) + } + } + + // Handle document type change + const handleDocumentTypeChange = async (documentNumberTypeId: string) => { + setFormData({ + ...formData, + documentNumberTypeId, + fieldValues: {} + }) + + if (documentNumberTypeId) { + const configsResult = await getDocumentNumberTypeConfigs(Number(documentNumberTypeId)) + if (configsResult.success) { + setSelectedTypeConfigs(configsResult.data) + + // Pre-load combobox options + const comboBoxPromises = configsResult.data + .filter(config => config.codeGroup?.controlType === 'combobox') + .map(async (config) => { + const optionsResult = await getComboBoxOptions(config.codeGroupId!) + return { + codeGroupId: config.codeGroupId, + options: optionsResult.success ? optionsResult.data : [] + } + }) + + const comboBoxResults = await Promise.all(comboBoxPromises) + const newComboBoxOptions: Record<number, any[]> = {} + comboBoxResults.forEach(result => { + if (result.codeGroupId) { + newComboBoxOptions[result.codeGroupId] = result.options + } + }) + setComboBoxOptions(newComboBoxOptions) + } + } else { + setSelectedTypeConfigs([]) + setComboBoxOptions({}) + } + } + + // Handle field value change + const handleFieldValueChange = (fieldKey: string, value: string) => { + setFormData({ + ...formData, + fieldValues: { + ...formData.fieldValues, + [fieldKey]: value + } + }) + } + + // Generate document number preview + const generatePreviewDocNumber = () => { + if (selectedTypeConfigs.length === 0) return "" + + let preview = "" + selectedTypeConfigs.forEach((config, index) => { + const fieldKey = `field_${config.sdq}` + const value = formData.fieldValues[fieldKey] || "[value]" + preview += value + if (index < selectedTypeConfigs.length - 1) { + preview += "-" + } + }) + return preview + } + + const handleSubmit = async () => { + if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title) { + toast.error("Please fill in all required fields.") + return + } + + setIsLoading(true) + try { + const result = await createDocument({ + contractId, + documentNumberTypeId: Number(formData.documentNumberTypeId), + documentClassId: Number(formData.documentClassId), + title: formData.title, + fieldValues: formData.fieldValues, + pic: formData.pic, + vendorDocNumber: formData.vendorDocNumber, + }) + + if (result.success) { + toast.success("Document added successfully.") + onOpenChange(false) + resetForm() + } else { + toast.error(result.error || "Error adding document.") + } + } catch (error) { + toast.error("Error adding document.") + } finally { + setIsLoading(false) + } + } + + const resetForm = () => { + setFormData({ + documentNumberTypeId: "", + documentClassId: "", + title: "", + pic: "", + vendorDocNumber: "", + fieldValues: {} + }) + setSelectedTypeConfigs([]) + setComboBoxOptions({}) + } + + const isPlantProject = projectType === "plant" + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col"> + <DialogHeader className="flex-shrink-0"> + <DialogTitle>Add New Document</DialogTitle> + <DialogDescription> + Enter the basic information for the new document. + </DialogDescription> + </DialogHeader> + + {isLoading ? ( + <div className="flex items-center justify-center py-8 flex-1"> + <Loader2 className="h-8 w-8 animate-spin" /> + </div> + ) : ( + <div className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* Document Number Type Selection */} + <div className="grid gap-2"> + <Label htmlFor="documentNumberTypeId"> + Document Number Type <span className="text-red-500">*</span> + </Label> + <Select + value={formData.documentNumberTypeId} + onValueChange={handleDocumentTypeChange} + > + <SelectTrigger> + <SelectValue placeholder="Select document number type" /> + </SelectTrigger> + <SelectContent> + {documentNumberTypes.map((type) => ( + <SelectItem key={type.id} value={String(type.id)}> + {type.name} - {type.description} + </SelectItem> + ))} + </SelectContent> + </Select> + </div> + + {/* Dynamic Fields */} + {selectedTypeConfigs.length > 0 && ( + <div className="border rounded-lg p-4 bg-blue-50/30"> + <Label className="text-sm font-medium text-blue-800 mb-3 block"> + Document Number Components + </Label> + <div className="grid gap-3"> + {selectedTypeConfigs.map((config) => ( + <div key={config.id} className="grid gap-2"> + <Label className="text-sm"> + {config.codeGroup?.description || config.description} + {config.required && <span className="text-red-500 ml-1">*</span>} + {config.remark && ( + <span className="text-xs text-gray-500 ml-2">({config.remark})</span> + )} + </Label> + + {config.codeGroup?.controlType === 'combobox' ? ( + <Select + value={formData.fieldValues[`field_${config.sdq}`] || ""} + onValueChange={(value) => handleFieldValueChange(`field_${config.sdq}`, value)} + > + <SelectTrigger> + <SelectValue placeholder="Select option" /> + </SelectTrigger> + <SelectContent> + {(comboBoxOptions[config.codeGroupId!] || []).map((option) => ( + <SelectItem key={option.id} value={option.code}> + {option.code} - {option.description} + </SelectItem> + ))} + </SelectContent> + </Select> + ) : config.documentClass ? ( + <div className="p-2 bg-gray-100 rounded text-sm"> + {config.documentClass.code} - {config.documentClass.description} + </div> + ) : ( + <Input + value={formData.fieldValues[`field_${config.sdq}`] || ""} + onChange={(e) => handleFieldValueChange(`field_${config.sdq}`, e.target.value)} + placeholder="Enter value" + /> + )} + </div> + ))} + </div> + + {/* Document Number Preview */} + <div className="mt-3 p-2 bg-white border rounded"> + <Label className="text-xs text-gray-600">Document Number Preview:</Label> + <div className="font-mono text-sm font-medium text-blue-600"> + {generatePreviewDocNumber()} + </div> + </div> + </div> + )} + + {/* Document Class Selection */} + <div className="grid gap-2"> + <Label htmlFor="documentClassId"> + Document Class <span className="text-red-500">*</span> + </Label> + <Select + value={formData.documentClassId} + onValueChange={(value) => setFormData({ ...formData, documentClassId: value })} + > + <SelectTrigger> + <SelectValue placeholder="Select document class" /> + </SelectTrigger> + <SelectContent> + {documentClasses.map((cls) => ( + <SelectItem key={cls.id} value={String(cls.id)}> + {cls.code} - {cls.description} + </SelectItem> + ))} + </SelectContent> + </Select> + {formData.documentClassId && ( + <p className="text-xs text-gray-600"> + Options from the selected class will be automatically created as stages. + </p> + )} + </div> + + {/* Document Title */} + <div className="grid gap-2"> + <Label htmlFor="title"> + Document Title <span className="text-red-500">*</span> + </Label> + <Input + id="title" + value={formData.title} + onChange={(e) => setFormData({ ...formData, title: e.target.value })} + placeholder="Enter document title" + /> + </div> + + {/* Additional Information */} + {isPlantProject && ( + <div className="grid gap-2"> + <Label htmlFor="vendorDocNumber">Vendor Document Number</Label> + <Input + id="vendorDocNumber" + value={formData.vendorDocNumber} + onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })} + placeholder="Vendor provided document number" + /> + </div> + )} + </div> + </div> + )} + + <DialogFooter className="flex-shrink-0"> + <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} + Add Document + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} + +// ============================================================================= +// 2. Edit Document Dialog (Updated with English text) +// ============================================================================= +interface EditDocumentDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + document: DocumentStagesOnlyView | null + contractId: number + projectType: "ship" | "plant" +} + +export function EditDocumentDialog({ + open, + onOpenChange, + document, + contractId, + projectType +}: EditDocumentDialogProps) { + const [formData, setFormData] = React.useState({ + title: "", + pic: "", + vendorDocNumber: "", + }) + + React.useEffect(() => { + if (document) { + setFormData({ + title: document.title || "", + pic: document.pic || "", + vendorDocNumber: document.vendorDocNumber || "", + }) + } + }, [document]) + + const handleSubmit = async () => { + try { + // TODO: API call to update document + toast.success("Document updated successfully.") + onOpenChange(false) + } catch (error) { + toast.error("Error updating document.") + } + } + + const isPlantProject = projectType === "plant" + + return ( + <Sheet open={open} onOpenChange={onOpenChange}> + <SheetContent className="sm:max-w-[500px]"> + <SheetHeader> + <SheetTitle>Edit Document</SheetTitle> + <SheetDescription> + You can modify the basic information of the document. + </SheetDescription> + </SheetHeader> + + <div className="grid gap-4 py-4"> + <div className="grid gap-2"> + <Label>Document Number</Label> + <div className="p-2 bg-gray-100 rounded text-sm font-mono"> + {document?.docNumber} + </div> + </div> + + <div className="grid gap-2"> + <Label htmlFor="edit-title"> + Document Title <span className="text-red-500">*</span> + </Label> + <Input + id="edit-title" + value={formData.title} + onChange={(e) => setFormData({ ...formData, title: e.target.value })} + placeholder="Enter document title" + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + {isPlantProject && ( + <div className="grid gap-2"> + <Label htmlFor="edit-vendorDocNumber">Vendor Document Number</Label> + <Input + id="edit-vendorDocNumber" + value={formData.vendorDocNumber} + onChange={(e) => setFormData({ ...formData, vendorDocNumber: e.target.value })} + placeholder="Vendor provided document number" + /> + </div> + )} + <div className="grid gap-2"> + <Label htmlFor="edit-pic">PIC</Label> + <Input + id="edit-pic" + value={formData.pic} + onChange={(e) => setFormData({ ...formData, pic: e.target.value })} + placeholder="Person in charge" + /> + </div> + </div> + </div> + + <SheetFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + Cancel + </Button> + <Button onClick={handleSubmit}> + Save Changes + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +} + +// ============================================================================= +// 3. Edit Stage Dialog (Updated with English text) +// ============================================================================= +interface EditStageDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + document: DocumentStagesOnlyView | null + stageId: number | null +} + +export function EditStageDialog({ + open, + onOpenChange, + document, + stageId +}: 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(() => { + 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 || "" + }) + } + } + }, [document, stageId]) + + const handleSubmit = async () => { + if (!stageId) return + + setIsLoading(true) + try { + const result = await updateStage({ + stageId, + ...formData + }) + + if (result.success) { + toast.success("Stage updated successfully.") + onOpenChange(false) + } else { + toast.error(result.error || "Error updating stage.") + } + } catch (error) { + toast.error("Error updating stage.") + } finally { + setIsLoading(false) + } + } + + 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> + <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> + </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 })} + > + <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> + </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> + </div> + + <div className="grid gap-2"> + <Label htmlFor="edit-assigneeName"> + <User className="inline w-4 h-4 mr-1" /> + Assignee + </Label> + <Input + id="edit-assigneeName" + value={formData.assigneeName} + onChange={(e) => setFormData({ ...formData, assigneeName: e.target.value })} + placeholder="Enter assignee name" + /> + </div> + + <div className="grid gap-2"> + <Label htmlFor="edit-notes">Notes</Label> + <Textarea + id="edit-notes" + value={formData.notes} + onChange={(e) => setFormData({ ...formData, notes: e.target.value })} + placeholder="Additional notes" + rows={3} + /> + </div> + </div> + </div> + + <DialogFooter className="flex-shrink-0"> + <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 (Updated with English text) +// ============================================================================= +interface ExcelImportDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + contractId: number + projectType: "ship" | "plant" +} + +export function ExcelImportDialog({ + open, + onOpenChange, + contractId, + projectType +}: ExcelImportDialogProps) { + const [file, setFile] = React.useState<File | null>(null) + const [isUploading, setIsUploading] = React.useState(false) + + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + const selectedFile = e.target.files?.[0] + if (selectedFile) { + setFile(selectedFile) + } + } + + const handleImport = async () => { + if (!file) { + toast.error("Please select a file.") + return + } + + setIsUploading(true) + try { + // TODO: API call to upload and process Excel file + toast.success("Excel file imported successfully.") + onOpenChange(false) + setFile(null) + } catch (error) { + toast.error("Error importing Excel file.") + } finally { + setIsUploading(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle> + <FileSpreadsheet className="inline w-5 h-5 mr-2" /> + Import Excel File + </DialogTitle> + <DialogDescription> + Upload an Excel file containing document list for batch registration. + </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} + </p> + )} + </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, PIC</p> + )} + <p>• Supported formats: .xlsx, .xls</p> + </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"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stage-validations.ts b/lib/vendor-document-list/plant/document-stage-validations.ts new file mode 100644 index 00000000..037293e3 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stage-validations.ts @@ -0,0 +1,339 @@ +// document-stage-validations.ts +import { z } from "zod" +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, +} from "nuqs/server" +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { DocumentStagesOnlyView } from "@/db/schema" + +// ============================================================================= +// 1. 문서 관련 스키마들 +// ============================================================================= + +// 문서 생성 스키마 +export const createDocumentSchema = z.object({ + contractId: z.number().min(1, "계약 ID는 필수입니다"), + docNumber: z.string().min(1, "문서번호는 필수입니다").max(100, "문서번호는 100자를 초과할 수 없습니다"), + title: z.string().min(1, "문서명은 필수입니다").max(255, "문서명은 255자를 초과할 수 없습니다"), + drawingKind: z.enum(["B3", "B4", "B5"], { + required_error: "문서종류를 선택해주세요", + }), + vendorDocNumber: z.string().max(100).optional(), + pic: z.string().max(50).optional(), + issuedDate: z.string().date().optional(), + + // DOLCE 연동 정보 + drawingMoveGbn: z.string().max(50).optional(), + discipline: z.string().max(10).optional(), + externalDocumentId: z.string().max(100).optional(), + externalSystemType: z.string().max(20).optional(), + + // B4 전용 필드들 + cGbn: z.string().max(50).optional(), + dGbn: z.string().max(50).optional(), + degreeGbn: z.string().max(50).optional(), + deptGbn: z.string().max(50).optional(), + jGbn: z.string().max(50).optional(), + sGbn: z.string().max(50).optional(), + + // 추가 필드들 + shiDrawingNo: z.string().max(100).optional(), + manager: z.string().max(100).optional(), + managerENM: z.string().max(100).optional(), + managerNo: z.string().max(50).optional(), +}) + +// 문서 업데이트 스키마 +export const updateDocumentSchema = createDocumentSchema.partial().extend({ + id: z.number().min(1, "문서 ID는 필수입니다"), +}) + +// 문서 삭제 스키마 +export const deleteDocumentSchema = z.object({ + id: z.number().min(1, "문서 ID는 필수입니다"), +}) + +// ============================================================================= +// 2. 스테이지 관련 스키마들 +// ============================================================================= + +// 스테이지 생성 스키마 +export const createStageSchema = z.object({ + documentId: z.number().min(1, "문서 ID는 필수입니다"), + stageName: z.string().min(1, "스테이지명은 필수입니다").max(100, "스테이지명은 100자를 초과할 수 없습니다"), + planDate: z.string().date().optional(), + stageStatus: z.enum(["PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"], { + required_error: "스테이지 상태를 선택해주세요", + }).default("PLANNED"), + stageOrder: z.number().min(0).default(0), + priority: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"), + assigneeId: z.number().optional(), + assigneeName: z.string().max(100).optional(), + reminderDays: z.number().min(0).max(30).default(3), + description: z.string().max(500).optional(), + notes: z.string().max(1000).optional(), +}) + +// 스테이지 업데이트 스키마 +export const updateStageSchema = createStageSchema.partial().extend({ + id: z.number().min(1, "스테이지 ID는 필수입니다"), + actualDate: z.string().date().optional(), +}) + +// 스테이지 삭제 스키마 +export const deleteStageSchema = z.object({ + id: z.number().min(1, "스테이지 ID는 필수입니다"), +}) + +// 스테이지 순서 변경 스키마 +export const reorderStagesSchema = z.object({ + documentId: z.number().min(1, "문서 ID는 필수입니다"), + stages: z.array(z.object({ + id: z.number().min(1), + stageOrder: z.number().min(0), + })).min(1, "최소 하나의 스테이지는 필요합니다"), +}) + +// ============================================================================= +// 3. 일괄 작업 스키마들 +// ============================================================================= + +// 일괄 문서 생성 스키마 (엑셀 임포트용) +export const bulkCreateDocumentsSchema = z.object({ + contractId: z.number().min(1, "계약 ID는 필수입니다"), + documents: z.array(createDocumentSchema.omit({ contractId: true })).min(1, "최소 하나의 문서는 필요합니다"), +}) + +// 일괄 스테이지 생성 스키마 +export const bulkCreateStagesSchema = z.object({ + documentId: z.number().min(1, "문서 ID는 필수입니다"), + stages: z.array(createStageSchema.omit({ documentId: true })).min(1, "최소 하나의 스테이지는 필요합니다"), +}) + +// 일괄 상태 업데이트 스키마 +export const bulkUpdateStatusSchema = z.object({ + stageIds: z.array(z.number().min(1)).min(1, "최소 하나의 스테이지를 선택해주세요"), + status: z.enum(["PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"]), + actualDate: z.string().date().optional(), +}) + +// 일괄 담당자 지정 스키마 +export const bulkAssignSchema = z.object({ + stageIds: z.array(z.number().min(1)).min(1, "최소 하나의 스테이지를 선택해주세요"), + assigneeId: z.number().optional(), + assigneeName: z.string().max(100).optional(), +}) + +// ============================================================================= +// 4. 검색 및 필터링 스키마들 +// ============================================================================= + +// 검색 파라미터 스키마 (Zod 검증용) +export const searchParamsSchema = z.object({ + page: z.coerce.number().default(1), + perPage: z.coerce.number().default(10), + sort: z.string().optional(), + search: z.string().optional(), + filters: z.string().optional(), + drawingKind: z.enum(["all", "B3", "B4", "B5"]).default("all"), + stageStatus: z.enum(["all", "PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"]).default("all"), + priority: z.enum(["all", "HIGH", "MEDIUM", "LOW"]).default("all"), + isOverdue: z.enum(["all", "true", "false"]).default("all"), + assignee: z.string().optional(), + dateFrom: z.string().date().optional(), + dateTo: z.string().date().optional(), + joinOperator: z.enum(["and", "or"]).default("and"), +}) + +// 문서 스테이지 전용 검색 파라미터 캐시 (nuqs용) +export const documentStageSearchParamsCache = createSearchParamsCache({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + sort: getSortingStateParser<DocumentStagesOnlyView>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // advanced filter + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + + // 문서 스테이지 전용 필터들 + drawingKind: parseAsStringEnum(["all", "B3", "B4", "B5"]).withDefault("all"), + stageStatus: parseAsStringEnum(["all", "PLANNED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED", "COMPLETED"]).withDefault("all"), + priority: parseAsStringEnum(["all", "HIGH", "MEDIUM", "LOW"]).withDefault("all"), + isOverdue: parseAsStringEnum(["all", "true", "false"]).withDefault("all"), + assignee: parseAsString.withDefault(""), + dateFrom: parseAsString.withDefault(""), + dateTo: parseAsString.withDefault(""), +}) + +// ============================================================================= +// 5. 엑셀 임포트 스키마들 +// ============================================================================= + +// 엑셀 문서 행 스키마 +export const excelDocumentRowSchema = z.object({ + "문서번호": z.string().min(1, "문서번호는 필수입니다"), + "문서명": z.string().min(1, "문서명은 필수입니다"), + "문서종류": z.enum(["B3", "B4", "B5"], { + required_error: "문서종류는 B3, B4, B5 중 하나여야 합니다", + }), + "벤더문서번호": z.string().optional(), + "PIC": z.string().optional(), + "발행일": z.string().optional(), + "벤더명": z.string().optional(), + "벤더코드": z.string().optional(), + // B4 전용 필드들 + "C구분": z.string().optional(), + "D구분": z.string().optional(), + "Degree구분": z.string().optional(), + "부서구분": z.string().optional(), + "S구분": z.string().optional(), + "J구분": z.string().optional(), +}) + +// 엑셀 스테이지 행 스키마 +export const excelStageRowSchema = z.object({ + "문서번호": z.string().min(1, "문서번호는 필수입니다"), + "스테이지명": z.string().min(1, "스테이지명은 필수입니다"), + "계획일": z.string().optional(), + "우선순위": z.enum(["HIGH", "MEDIUM", "LOW"]).optional(), + "담당자": z.string().optional(), + "설명": z.string().optional(), + "스테이지순서": z.coerce.number().optional(), +}) + +// 엑셀 임포트 결과 스키마 +export const excelImportResultSchema = z.object({ + totalRows: z.number(), + successCount: z.number(), + failureCount: z.number(), + errors: z.array(z.object({ + row: z.number(), + field: z.string().optional(), + message: z.string(), + })), + createdDocuments: z.array(z.object({ + id: z.number(), + docNumber: z.string(), + title: z.string(), + })), +}) + +// ============================================================================= +// 6. API 응답 스키마들 +// ============================================================================= + +// 표준 API 응답 스키마 +export const apiResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => + z.object({ + success: z.boolean(), + data: dataSchema.optional(), + error: z.string().optional(), + message: z.string().optional(), + }) + +// 페이지네이션 응답 스키마 +export const paginatedResponseSchema = <T extends z.ZodTypeAny>(dataSchema: T) => + z.object({ + data: z.array(dataSchema), + pageCount: z.number(), + total: z.number(), + page: z.number(), + perPage: z.number(), + }) + +// ============================================================================= +// 7. 타입 추출 +// ============================================================================= + +export type CreateDocumentInput = z.infer<typeof createDocumentSchema> +export type UpdateDocumentInput = z.infer<typeof updateDocumentSchema> +export type DeleteDocumentInput = z.infer<typeof deleteDocumentSchema> + +export type CreateStageInput = z.infer<typeof createStageSchema> +export type UpdateStageInput = z.infer<typeof updateStageSchema> +export type DeleteStageInput = z.infer<typeof deleteStageSchema> +export type ReorderStagesInput = z.infer<typeof reorderStagesSchema> + +export type BulkCreateDocumentsInput = z.infer<typeof bulkCreateDocumentsSchema> +export type BulkCreateStagesInput = z.infer<typeof bulkCreateStagesSchema> +export type BulkUpdateStatusInput = z.infer<typeof bulkUpdateStatusSchema> +export type BulkAssignInput = z.infer<typeof bulkAssignSchema> + +export type SearchParamsInput = z.infer<typeof searchParamsSchema> +export type ExcelDocumentRow = z.infer<typeof excelDocumentRowSchema> +export type ExcelStageRow = z.infer<typeof excelStageRowSchema> +export type ExcelImportResult = z.infer<typeof excelImportResultSchema> + +// ============================================================================= +// 8. 유틸리티 함수들 +// ============================================================================= + +// 문서번호 유효성 검사 (프로젝트별 규칙) +export const validateDocNumber = (docNumber: string, projectType: "ship" | "plant") => { + if (projectType === "ship") { + // Ship 프로젝트: 특정 패턴 검사 + const shipPattern = /^[A-Z]{2,4}-\d{4}-\d{3}$/ + return shipPattern.test(docNumber) + } else { + // Plant 프로젝트: 더 유연한 패턴 + const plantPattern = /^[A-Z0-9-]{5,20}$/ + return plantPattern.test(docNumber) + } +} + +// B4 필드 유효성 검사 +export const validateB4Fields = (data: Partial<CreateDocumentInput>) => { + if (data.drawingKind === "B4") { + const requiredB4Fields = ["cGbn", "dGbn", "deptGbn"] + const missingFields = requiredB4Fields.filter(field => + !data[field as keyof typeof data] || + String(data[field as keyof typeof data]).trim() === "" + ) + + if (missingFields.length > 0) { + throw new Error(`B4 문서는 다음 필드가 필수입니다: ${missingFields.join(", ")}`) + } + } +} + +// 스테이지 순서 유효성 검사 +export const validateStageOrder = (stages: { id: number; stageOrder: number }[]) => { + const orders = stages.map(s => s.stageOrder) + const uniqueOrders = new Set(orders) + + if (orders.length !== uniqueOrders.size) { + throw new Error("스테이지 순서는 중복될 수 없습니다") + } + + const sortedOrders = [...orders].sort((a, b) => a - b) + const expectedOrders = Array.from({ length: orders.length }, (_, i) => i) + + if (JSON.stringify(sortedOrders) !== JSON.stringify(expectedOrders)) { + throw new Error("스테이지 순서는 0부터 연속된 숫자여야 합니다") + } +} + +// 날짜 유효성 검사 +export const validateDateRange = (startDate?: string, endDate?: string) => { + if (startDate && endDate) { + const start = new Date(startDate) + const end = new Date(endDate) + + if (start > end) { + throw new Error("시작일은 종료일보다 이전이어야 합니다") + } + + // 최대 1년 범위 제한 + const oneYearInMs = 365 * 24 * 60 * 60 * 1000 + if (end.getTime() - start.getTime() > oneYearInMs) { + throw new Error("날짜 범위는 최대 1년까지 가능합니다") + } + } +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx new file mode 100644 index 00000000..d39af4e8 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -0,0 +1,521 @@ +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { DocumentStagesOnlyView } from "@/db/schema" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { + Ellipsis, + AlertTriangle, + Clock, + CheckCircle, + Calendar, + User, + Eye, + Edit, + Plus, + Trash2 +} from "lucide-react" +import { cn } from "@/lib/utils" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<DocumentStagesOnlyView> | null>> + projectType: string +} + +// 유틸리티 함수들 +const getStatusColor = (status: string, isOverdue = false) => { + if (isOverdue) return 'destructive' + switch (status) { + case 'COMPLETED': case 'APPROVED': return 'success' + case 'IN_PROGRESS': return 'default' + case 'SUBMITTED': case 'UNDER_REVIEW': return 'secondary' + case 'REJECTED': return 'destructive' + default: return 'outline' + } +} + +const getPriorityColor = (priority: string) => { + switch (priority) { + case 'HIGH': return 'destructive' + case 'MEDIUM': return 'default' + case 'LOW': return 'secondary' + default: return 'outline' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return 'Planned' + case 'IN_PROGRESS': return 'In Progress' + case 'SUBMITTED': return 'Submitted' + case 'UNDER_REVIEW': return 'Under Review' + case 'APPROVED': return 'Approved' + case 'REJECTED': return 'Rejected' + case 'COMPLETED': return 'Completed' + default: return status + } +} + +const getPriorityText = (priority: string) => { + switch (priority) { + case 'HIGH': return 'High' + case 'MEDIUM': return 'Medium' + case 'LOW': return 'Low' + default: priority + } +} + +// 마감일 정보 컴포넌트 (콤팩트) +const DueDateInfo = ({ + daysUntilDue, + isOverdue, + className = "" +}: { + daysUntilDue: number | null + isOverdue: boolean + className?: string +}) => { + if (isOverdue && daysUntilDue !== null && daysUntilDue < 0) { + return ( + <span className={cn("inline-flex items-center gap-1 text-red-600 text-xs", className)}> + <AlertTriangle className="w-3 h-3" /> + {Math.abs(daysUntilDue)}d overdue + </span> + ) + } + + if (daysUntilDue === 0) { + return ( + <span className={cn("inline-flex items-center gap-1 text-orange-600 text-xs", className)}> + <Clock className="w-3 h-3" /> + Due today + </span> + ) + } + + if (daysUntilDue && daysUntilDue > 0 && daysUntilDue <= 3) { + return ( + <span className={cn("inline-flex items-center gap-1 text-orange-600 text-xs", className)}> + <Clock className="w-3 h-3" /> + {daysUntilDue}d left + </span> + ) + } + + if (daysUntilDue && daysUntilDue > 0) { + return ( + <span className={cn("inline-flex items-center gap-1 text-gray-500 text-xs", className)}> + <Calendar className="w-3 h-3" /> + {daysUntilDue}d + </span> + ) + } + + return ( + <span className={cn("inline-flex items-center gap-1 text-green-600 text-xs", className)}> + <CheckCircle className="w-3 h-3" /> + Done + </span> + ) +} + +export function getDocumentStagesColumns({ + setRowAction, + projectType +}: GetColumnsProps): ColumnDef<DocumentStagesOnlyView>[] { + const isPlantProject = projectType === "plant" + + const columns: ColumnDef<DocumentStagesOnlyView>[] = [ + // 체크박스 선택 + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "projectCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Project" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <span className="text-sm font-medium">{doc.projectCode}</span> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "Project" + }, + }, + + + + // 문서번호 + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Number" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <span className="font-mono text-sm font-medium">{doc.docNumber}</span> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "Document Number" + }, + }, + + // 문서명 (PIC 포함) + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Document Name" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="min-w-0 flex-1"> + <div className="font-medium text-gray-900 truncate text-sm" title={doc.title}> + {doc.title} + </div> + {doc.pic && ( + <span className="text-xs text-gray-500 bg-gray-100 px-1.5 py-0.5 rounded mt-1 inline-block"> + PIC: {doc.pic} + </span> + )} + </div> + ) + }, + size: 220, + enableResizing: true, + meta: { + excelHeader: "Document Name" + }, + }, + ] + + // Plant 프로젝트용 추가 컬럼들 + if (isPlantProject) { + columns.push( + // 벤더 문서번호 + { + accessorKey: "vendorDocNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Vendor Doc No." /> + ), + cell: ({ row }) => { + const doc = row.original + return doc.vendorDocNumber ? ( + <span className="font-mono text-sm text-blue-600">{doc.vendorDocNumber}</span> + ) : ( + <span className="text-gray-400 text-sm">-</span> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "Vendor Doc No." + }, + }, + + ) + } + + // 나머지 공통 컬럼들 + columns.push( + // 현재 스테이지 (상태, 담당자 한 줄) + { + accessorKey: "currentStageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Current Stage" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStageName) { + return ( + <Button + size="sm" + variant="outline" + onClick={(e) => { + e.stopPropagation() + setRowAction({ row, type: "add_stage" }) + }} + className="h-6 text-xs" + > + <Plus className="w-3 h-3 mr-1" /> + Add stage + </Button> + ) + } + + return ( + <div className="flex items-center gap-2"> + <span className="text-sm font-medium truncate" title={doc.currentStageName}> + {doc.currentStageName} + </span> + <Badge + variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} + className="text-xs px-1.5 py-0" + > + {getStatusText(doc.currentStageStatus || '')} + </Badge> + {doc.currentStageAssigneeName && ( + <span className="text-xs text-gray-500 flex items-center gap-1"> + <User className="w-3 h-3" /> + {doc.currentStageAssigneeName} + </span> + )} + </div> + ) + }, + size: 180, + enableResizing: true, + meta: { + excelHeader: "Current Stage" + }, + }, + + // 계획 일정 (한 줄) + { + accessorKey: "currentStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Plan Date" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStagePlanDate) return <span className="text-gray-400">-</span> + + return ( + <div className="flex items-center gap-2"> + <span className="text-sm">{formatDate(doc.currentStagePlanDate, 'MM/dd')}</span> + <DueDateInfo + daysUntilDue={doc.daysUntilDue} + isOverdue={doc.isOverdue || false} + /> + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "Plan Date" + }, + }, + + // 우선순위 + 진행률 (콤팩트) + { + accessorKey: "progressPercentage", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Priority/Progress" /> + ), + cell: ({ row }) => { + const doc = row.original + const progress = doc.progressPercentage || 0 + const completed = doc.completedStages || 0 + const total = doc.totalStages || 0 + + return ( + <div className="flex items-center gap-2"> + {doc.currentStagePriority && ( + <Badge + variant={getPriorityColor(doc.currentStagePriority)} + className="text-xs px-1.5 py-0" + > + {getPriorityText(doc.currentStagePriority)} + </Badge> + )} + <div className="flex items-center gap-1"> + <Progress value={progress} className="w-12 h-1.5" /> + <span className="text-xs text-gray-600 min-w-[2rem]"> + {progress}% + </span> + </div> + <span className="text-xs text-gray-500"> + ({completed}/{total}) + </span> + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "Progress" + }, + }, + + // 업데이트 일시 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated At" /> + ), + cell: ({ cell }) => ( + <span className="text-xs text-gray-600"> + {formatDateTime(cell.getValue() as Date)} + </span> + ), + size: 80, + enableResizing: true, + meta: { + excelHeader: "Updated At" + }, + }, + + // 액션 메뉴 + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const doc = row.original + const hasStages = doc.totalStages && doc.totalStages > 0 + + const viewActions = [ + { + key: "view", + label: "View Stage Details", + icon: Eye, + action: () => setRowAction({ row, type: "view" }), + show: hasStages + } + ] + + const manageActions = [ + { + key: "edit_document", + label: "Edit Document", + icon: Edit, + action: () => setRowAction({ row, type: "edit_document" }), + show: true + } + ] + + const dangerActions = [ + { + key: "delete", + label: "Delete Document", + icon: Trash2, + action: () => setRowAction({ row, type: "delete" }), + show: true, + className: "text-red-600", + shortcut: "⌘⌫" + } + ] + + const hasViewActions = viewActions.some(action => action.show) + const hasManageActions = manageActions.some(action => action.show) + const hasDangerActions = dangerActions.some(action => action.show) + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-6 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-3" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + {hasViewActions && ( + <> + {viewActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-3 w-3" /> + <span className="text-xs">{action.label}</span> + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + {hasManageActions && <DropdownMenuSeparator />} + </> + )} + + {manageActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-3 w-3" /> + <span className="text-xs">{action.label}</span> + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + + {hasDangerActions && ( + <> + <DropdownMenuSeparator /> + {dangerActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-3 w-3" /> + <span className="text-xs">{action.label}</span> + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ) + + return columns +}
\ 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 new file mode 100644 index 00000000..2f6b637c --- /dev/null +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -0,0 +1,136 @@ +"use client" + +import React from "react" +import { DocumentStagesOnlyView } from "@/db/schema" +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 { + Calendar, + CheckCircle, + Edit, + FileText +} from "lucide-react" +import { formatDate } from "@/lib/utils" +import { cn } from "@/lib/utils" + +interface DocumentStagesExpandedContentProps { + document: DocumentStagesOnlyView + onEditStage: (stageId: number) => void + projectType: "ship" | "plant" +} + +// 상태별 색상 및 텍스트 유틸리티 +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 + } +} + +export function DocumentStagesExpandedContent({ + document, + onEditStage, + projectType +}: DocumentStagesExpandedContentProps) { + const stages = document.allStages || [] + const sortedStages = stages.sort((a, b) => a.stageOrder - b.stageOrder) + + return ( + <div className="bg-gray-50 border-t p-3"> + {stages.length === 0 ? ( + <div className="text-center py-3 text-gray-500 text-sm"> + 등록된 스테이지가 없습니다. + </div> + ) : ( + <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-2"> + {sortedStages.map((stage, index) => { + const isCurrentStage = stage.id === document.currentStageId + const planDate = stage.planDate ? new Date(stage.planDate) : null + const actualDate = stage.actualDate ? new Date(stage.actualDate) : null + + return ( + <div + key={stage.id} + className={cn( + "relative p-2 rounded-md border text-xs transition-colors hover:shadow-sm", + isCurrentStage + ? "bg-blue-50 border-blue-200" + : "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> + + {/* 스테이지명 */} + <div className="mb-2 pr-6"> + <div className="font-medium text-sm truncate" title={stage.stageName}> + {stage.stageName} + </div> + {isCurrentStage && ( + <Badge variant="default" className="text-xs px-1 py-0 mt-1"> + 현재 + </Badge> + )} + </div> + + {/* 상태 */} + <div className="mb-2"> + <Badge + variant={getStatusVariant(stage.stageStatus)} + className="text-xs px-1.5 py-0" + > + {getStatusText(stage.stageStatus)} + </Badge> + </div> + + {/* 날짜 정보 */} + <div className="space-y-1 text-xs text-gray-600 mb-2"> + {planDate && ( + <div className="flex items-center gap-1"> + <Calendar className="h-3 w-3" /> + <span>계획: {formatDate(planDate.toISOString(), 'MM/dd')}</span> + </div> + )} + {actualDate && ( + <div className="flex items-center gap-1"> + <CheckCircle className="h-3 w-3 text-green-500" /> + <span>실적: {formatDate(actualDate.toISOString(), 'MM/dd')}</span> + </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> + ) + })} + </div> + )} + </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 new file mode 100644 index 00000000..2fd20fa4 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -0,0 +1,1097 @@ +// document-stage-actions.ts +"use server" + +import { revalidatePath, revalidateTag } from "next/cache" +import { redirect } from "next/navigation" +import db from "@/db/db" +import { codeGroups, comboBoxSettings, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages } from "@/db/schema" +import { and, eq, asc, desc, sql, inArray, max } from "drizzle-orm" +import { + createDocumentSchema, + updateDocumentSchema, + deleteDocumentSchema, + createStageSchema, + updateStageSchema, + deleteStageSchema, + reorderStagesSchema, + bulkCreateDocumentsSchema, + bulkUpdateStatusSchema, + bulkAssignSchema, + validateDocNumber, + validateB4Fields, + validateStageOrder, + type CreateDocumentInput, + type UpdateDocumentInput, + type CreateStageInput, + type UpdateStageInput, + type ExcelImportResult, +} from "./document-stage-validations" +import { unstable_noStore as noStore } from "next/cache" +import { filterColumns } from "@/lib/filter-columns" +import { GetEnhancedDocumentsSchema } from "../enhanced-document-service" +import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository" + +// ============================================================================= +// 1. 문서 관련 액션들 +// ============================================================================= + +// 문서 생성 +// export async function createDocument(input: CreateDocumentInput) { +// noStore() + +// try { +// // 입력값 검증 +// const validatedData = createDocumentSchema.parse(input) + +// // 프로젝트 타입 확인 (계약 정보에서 가져와야 함) +// const contract = await db.query.contracts.findFirst({ +// where: eq(documents.contractId, validatedData.contractId), +// with: { project: true } +// }) + +// if (!contract) { +// throw new Error("계약 정보를 찾을 수 없습니다") +// } + +// const projectType = contract.project?.type === "plant" ? "plant" : "ship" + +// // 문서번호 유효성 검사 +// if (!validateDocNumber(validatedData.docNumber, projectType)) { +// throw new Error(`${projectType === "ship" ? "선박" : "플랜트"} 프로젝트의 문서번호 형식에 맞지 않습니다`) +// } + +// // B4 필드 유효성 검사 +// validateB4Fields(validatedData) + +// // 문서번호 중복 검사 +// const existingDoc = await db.query.documents.findFirst({ +// where: and( +// eq(documents.contractId, validatedData.contractId), +// eq(documents.docNumber, validatedData.docNumber), +// eq(documents.status, "ACTIVE") +// ) +// }) + +// if (existingDoc) { +// throw new Error("이미 존재하는 문서번호입니다") +// } + +// // 문서 생성 +// const [newDocument] = await db.insert(documents).values({ +// contractId: validatedData.contractId, +// docNumber: validatedData.docNumber, +// title: validatedData.title, +// drawingKind: validatedData.drawingKind, +// vendorDocNumber: validatedData.vendorDocNumber || null, +// pic: validatedData.pic || null, +// issuedDate: validatedData.issuedDate || null, +// drawingMoveGbn: validatedData.drawingMoveGbn || null, +// discipline: validatedData.discipline || null, +// externalDocumentId: validatedData.externalDocumentId || null, +// externalSystemType: validatedData.externalSystemType || null, +// cGbn: validatedData.cGbn || null, +// dGbn: validatedData.dGbn || null, +// degreeGbn: validatedData.degreeGbn || null, +// deptGbn: validatedData.deptGbn || null, +// jGbn: validatedData.jGbn || null, +// sGbn: validatedData.sGbn || null, +// shiDrawingNo: validatedData.shiDrawingNo || null, +// manager: validatedData.manager || null, +// managerENM: validatedData.managerENM || null, +// managerNo: validatedData.managerNo || null, +// status: "ACTIVE", +// createdAt: new Date(), +// updatedAt: new Date(), +// }).returning() + +// // 캐시 무효화 +// revalidateTag(`documents-${validatedData.contractId}`) +// revalidatePath(`/contracts/${validatedData.contractId}/documents`) + +// return { +// success: true, +// data: newDocument, +// message: "문서가 성공적으로 생성되었습니다" +// } + +// } catch (error) { +// console.error("Error creating document:", error) +// return { +// success: false, +// error: error instanceof Error ? error.message : "문서 생성 중 오류가 발생했습니다" +// } +// } +// } + +// 문서 수정 +export async function updateDocument(input: UpdateDocumentInput) { + noStore() + + try { + const validatedData = updateDocumentSchema.parse(input) + + // 문서 존재 확인 + const existingDoc = await db.query.documents.findFirst({ + where: eq(documents.id, validatedData.id) + }) + + if (!existingDoc) { + throw new Error("문서를 찾을 수 없습니다") + } + + // B4 필드 유효성 검사 (drawingKind 변경 시) + if (validatedData.drawingKind) { + validateB4Fields(validatedData) + } + + // 문서번호 중복 검사 (문서번호 변경 시) + if (validatedData.docNumber && validatedData.docNumber !== existingDoc.docNumber) { + const duplicateDoc = await db.query.documents.findFirst({ + where: and( + eq(documents.contractId, existingDoc.contractId), + eq(documents.docNumber, validatedData.docNumber), + eq(documents.status, "ACTIVE") + ) + }) + + if (duplicateDoc) { + throw new Error("이미 존재하는 문서번호입니다") + } + } + + // 문서 업데이트 + const [updatedDocument] = await db + .update(documents) + .set({ + ...validatedData, + updatedAt: new Date(), + }) + .where(eq(documents.id, validatedData.id)) + .returning() + + // 캐시 무효화 + revalidateTag(`documents-${existingDoc.contractId}`) + revalidatePath(`/contracts/${existingDoc.contractId}/documents`) + + return { + success: true, + data: updatedDocument, + message: "문서가 성공적으로 수정되었습니다" + } + + } catch (error) { + console.error("Error updating document:", error) + return { + success: false, + error: error instanceof Error ? error.message : "문서 수정 중 오류가 발생했습니다" + } + } +} + +// 문서 삭제 (소프트 삭제) +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) + .set({ + status: "DELETED", + 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 { + success: false, + error: error instanceof Error ? error.message : "문서 삭제 중 오류가 발생했습니다" + } + } +} + +// ============================================================================= +// 2. 스테이지 관련 액션들 +// ============================================================================= + +// 스테이지 생성 +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( + eq(issueStages.documentId, validatedData.documentId), + eq(issueStages.stageName, validatedData.stageName) + ) + }) + + if (existingStage) { + throw new Error("이미 존재하는 스테이지명입니다") + } + + // 스테이지 순서 자동 설정 (제공되지 않은 경우) + let stageOrder = validatedData.stageOrder + if (stageOrder === 0 || stageOrder === undefined) { + const maxOrderResult = await db + .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, + stageName: validatedData.stageName, + planDate: validatedData.planDate || null, + stageStatus: validatedData.stageStatus, + stageOrder, + priority: validatedData.priority, + assigneeId: validatedData.assigneeId || null, + assigneeName: validatedData.assigneeName || null, + reminderDays: validatedData.reminderDays, + description: validatedData.description || null, + notes: validatedData.notes || null, + 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 { + success: false, + error: error instanceof Error ? error.message : "스테이지 생성 중 오류가 발생했습니다" + } + } +} + +// 스테이지 수정 +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), + with: { + document: true + } + }) + + if (!existingStage) { + throw new Error("스테이지를 찾을 수 없습니다") + } + + // 스테이지명 중복 검사 (스테이지명 변경 시) + if (validatedData.stageName && validatedData.stageName !== existingStage.stageName) { + const duplicateStage = await db.query.issueStages.findFirst({ + where: and( + eq(issueStages.documentId, existingStage.documentId), + eq(issueStages.stageName, validatedData.stageName) + ) + }) + + if (duplicateStage) { + throw new Error("이미 존재하는 스테이지명입니다") + } + } + + // 스테이지 업데이트 + const [updatedStage] = await db + .update(issueStages) + .set({ + ...validatedData, + updatedAt: new Date(), + }) + .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 { + success: false, + error: error instanceof Error ? error.message : "스테이지 수정 중 오류가 발생했습니다" + } + } +} + +// 스테이지 삭제 +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), + with: { + 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 + .update(issueStages) + .set({ stageOrder: i, updatedAt: new Date() }) + .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 { + success: false, + error: error instanceof Error ? error.message : "스테이지 삭제 중 오류가 발생했습니다" + } + } +} + +// 스테이지 순서 변경 +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({ + where: and( + eq(issueStages.documentId, validatedData.documentId), + 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({ + 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 { + success: false, + error: error instanceof Error ? error.message : "스테이지 순서 변경 중 오류가 발생했습니다" + } + } +} + +// ============================================================================= +// 3. 일괄 작업 액션들 +// ============================================================================= + +// 일괄 문서 생성 (엑셀 임포트) +export async function bulkCreateDocuments(input: any): Promise<ExcelImportResult> { + noStore() + + try { + const validatedData = bulkCreateDocumentsSchema.parse(input) + + const result: ExcelImportResult = { + totalRows: validatedData.documents.length, + successCount: 0, + failureCount: 0, + 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({ + where: and( + eq(documents.contractId, validatedData.contractId), + eq(documents.docNumber, docData.docNumber), + eq(documents.status, "ACTIVE") + ) + }) + + if (existingDoc) { + result.errors.push({ + row: i + 2, // 엑셀 행 번호 (헤더 포함) + field: "docNumber", + message: `문서번호 '${docData.docNumber}'가 이미 존재합니다` + }) + result.failureCount++ + continue + } + + // 문서 생성 + const [newDoc] = await tx.insert(documents).values({ + contractId: validatedData.contractId, + docNumber: docData.docNumber, + title: docData.title, + drawingKind: docData.drawingKind, + vendorDocNumber: docData.vendorDocNumber || null, + pic: docData.pic || null, + issuedDate: docData.issuedDate || null, + cGbn: docData.cGbn || null, + dGbn: docData.dGbn || null, + degreeGbn: docData.degreeGbn || null, + deptGbn: docData.deptGbn || null, + jGbn: docData.jGbn || null, + sGbn: docData.sGbn || null, + status: "ACTIVE", + 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, + message: error instanceof Error ? error.message : "알 수 없는 오류" + }) + result.failureCount++ + } + } + }) + + // 캐시 무효화 + revalidateTag(`documents-${validatedData.contractId}`) + revalidatePath(`/contracts/${validatedData.contractId}/documents`) + + return result + + } catch (error) { + console.error("Error bulk creating documents:", error) + throw new Error("일괄 문서 생성 중 오류가 발생했습니다") + } +} + +// 일괄 상태 업데이트 +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) + .set({ + stageStatus: validatedData.status, + actualDate: validatedData.actualDate || null, + 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 { + success: false, + error: error instanceof Error ? error.message : "일괄 상태 업데이트 중 오류가 발생했습니다" + } + } +} + +// 일괄 담당자 지정 +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) + .set({ + assigneeId: validatedData.assigneeId || null, + assigneeName: validatedData.assigneeName || null, + 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 { + success: false, + error: error instanceof Error ? error.message : "일괄 담당자 지정 중 오류가 발생했습니다" + } + } +} + + +// 문서번호 타입 목록 조회 +export async function getDocumentNumberTypes() { + try { + const types = await db + .select() + .from(documentNumberTypes) + .where(eq(documentNumberTypes.isActive, true)) + .orderBy(asc(documentNumberTypes.name)) + + return { success: true, data: types } + } catch (error) { + console.error("문서번호 타입 조회 실패:", error) + return { success: false, error: "문서번호 타입을 불러올 수 없습니다." } + } +} + +// 문서번호 타입 설정 조회 +export async function getDocumentNumberTypeConfigs(documentNumberTypeId: number) { + try { + const configs = await db + .select({ + id: documentNumberTypeConfigs.id, + sdq: documentNumberTypeConfigs.sdq, + description: documentNumberTypeConfigs.description, + remark: documentNumberTypeConfigs.remark, + codeGroupId: documentNumberTypeConfigs.codeGroupId, + 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, + } + }) + .from(documentNumberTypeConfigs) + .leftJoin(codeGroups, eq(documentNumberTypeConfigs.codeGroupId, codeGroups.id)) + .leftJoin(documentClasses, eq(documentNumberTypeConfigs.documentClassId, documentClasses.id)) + .where( + and( + eq(documentNumberTypeConfigs.documentNumberTypeId, documentNumberTypeId), + eq(documentNumberTypeConfigs.isActive, true) + ) + ) + .orderBy(asc(documentNumberTypeConfigs.sdq)) + + console.log(configs,"configs") + + return { success: true, data: configs } + } catch (error) { + console.error("문서번호 타입 설정 조회 실패:", error) + return { success: false, error: "문서번호 설정을 불러올 수 없습니다." } + } +} + +// 콤보박스 옵션 조회 +export async function getComboBoxOptions(codeGroupId: number) { + console.log(codeGroupId,"codeGroupId") + try { + const settings = await db + .select({ + id: comboBoxSettings.id, + code: comboBoxSettings.code, + description: comboBoxSettings.description, + remark: comboBoxSettings.remark, + }) + .from(comboBoxSettings) + .where(eq(comboBoxSettings.codeGroupId, codeGroupId)) + .orderBy(asc(comboBoxSettings.code)) + + console.log("settings",settings) + + return { success: true, data: settings } + } catch (error) { + console.error("콤보박스 옵션 조회 실패:", error) + return { success: false, error: "콤보박스 옵션을 불러올 수 없습니다." } + } +} + +// 문서 클래스 목록 조회 +export async function getDocumentClasses() { + try { + const classes = await db + .select() + .from(documentClasses) + .where(eq(documentClasses.isActive, true)) + .orderBy(asc(documentClasses.description)) + + return { success: true, data: classes } + } catch (error) { + console.error("문서 클래스 조회 실패:", error) + return { success: false, error: "문서 클래스를 불러올 수 없습니다." } + } +} + +// 문서 클래스 옵션 조회 (스테이지로 사용) +export async function getDocumentClassOptions(documentClassId: number) { + try { + const options = await db + .select() + .from(documentClassOptions) + .where( + and( + eq(documentClassOptions.documentClassId, documentClassId), + eq(documentClassOptions.isActive, true) + ) + ) + .orderBy(asc(documentClassOptions.sortOrder)) + + return { success: true, data: options } + } catch (error) { + console.error("문서 클래스 옵션 조회 실패:", error) + return { success: false, error: "문서 클래스 옵션을 불러올 수 없습니다." } + } +} + +// 문서번호 생성 +export async function generateDocumentNumber(configs: any[], values: Record<string, string>) { + let docNumber = "" + + configs.forEach((config) => { + const value = values[`field_${config.sdq}`] || "" + if (value) { + docNumber += value + // 구분자가 필요한 경우 추가 (하이픈 등) + if (config.sdq < configs.length) { + docNumber += "-" + } + } + }) + + return docNumber.replace(/-$/, "") // 마지막 하이픈 제거 +} + +// 문서 생성 +export async function createDocument(data: { + contractId: number + documentNumberTypeId: number + documentClassId: number + title: string + fieldValues: Record<string, string> + pic?: string + vendorDocNumber?: string +}) { + try { + // 1. 문서번호 타입 설정 조회 + const configsResult = await getDocumentNumberTypeConfigs(data.documentNumberTypeId) + if (!configsResult.success) { + return { success: false, error: configsResult.error } + } + + // 2. 문서번호 생성 + const documentNumber = generateDocumentNumber(configsResult.data, data.fieldValues) + + // 3. 문서 생성 (실제 documents 테이블에 INSERT) + // TODO: 실제 documents 테이블 스키마에 맞게 수정 필요 + /* + const [document] = await db.insert(documents).values({ + contractId: data.contractId, + docNumber: documentNumber, + title: data.title, + documentClassId: data.documentClassId, + pic: data.pic, + vendorDocNumber: data.vendorDocNumber, + }).returning() + */ + + // 4. 문서 클래스의 옵션들을 스테이지로 자동 생성 + const stageOptionsResult = await getDocumentClassOptions(data.documentClassId) + if (stageOptionsResult.success && stageOptionsResult.data.length > 0) { + // TODO: 실제 stages 테이블에 스테이지들 생성 + /* + const stageInserts = stageOptionsResult.data.map((option, index) => ({ + documentId: document.id, + stageName: option.optionValue, + stageOrder: option.sortOrder || index + 1, + stageStatus: 'PLANNED', + // 기본값들... + })) + + await db.insert(documentStages).values(stageInserts) + */ + } + + revalidatePath(`/contracts/${data.contractId}/documents`) + + return { + success: true, + data: { + documentNumber, + // document + } + } + } catch (error) { + console.error("문서 생성 실패:", error) + return { success: false, error: "문서 생성 중 오류가 발생했습니다." } + } +} + +// 스테이지 업데이트 +// export async function updateStage(data: { +// stageId: number +// stageName?: string +// planDate?: string +// actualDate?: string +// stageStatus?: string +// assigneeName?: string +// priority?: string +// notes?: string +// }) { +// try { +// // TODO: 실제 stages 테이블 업데이트 +// /* +// await db +// .update(documentStages) +// .set({ +// ...data, +// updatedAt: new Date(), +// }) +// .where(eq(documentStages.id, data.stageId)) +// */ + +// revalidatePath("/contracts/[contractId]/documents", "page") + +// return { success: true } +// } catch (error) { +// console.error("스테이지 업데이트 실패:", error) +// return { success: false, error: "스테이지 업데이트 중 오류가 발생했습니다." } +// } +// } + +export async function getDocumentStagesOnly( + input: GetEnhancedDocumentsSchema, + contractId: number +) { + try { + const offset = (input.page - 1) * input.perPage + + // 고급 필터 처리 + const advancedWhere = filterColumns({ + table: documentStagesOnlyView, + filters: input.filters || [], + joinOperator: input.joinOperator || "and", + }) + + // 전역 검색 처리 + let globalWhere + if (input.search) { + const searchTerm = `%${input.search}%` + globalWhere = or( + ilike(documentStagesOnlyView.title, searchTerm), + ilike(documentStagesOnlyView.docNumber, searchTerm), + ilike(documentStagesOnlyView.currentStageName, searchTerm), + ilike(documentStagesOnlyView.currentStageAssigneeName, searchTerm), + ilike(documentStagesOnlyView.vendorDocNumber, searchTerm), + ilike(documentStagesOnlyView.pic, searchTerm) + ) + } + + // 최종 WHERE 조건 + const finalWhere = and( + advancedWhere, + globalWhere, + eq(documentStagesOnlyView.contractId, contractId) + ) + + // 정렬 처리 + const orderBy = input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(documentStagesOnlyView[item.id]) + : asc(documentStagesOnlyView[item.id]) + ) + : [desc(documentStagesOnlyView.createdAt)] + + // 트랜잭션 실행 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectDocumentStagesOnly(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }) + + const total = await countDocumentStagesOnly(tx, finalWhere) + + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount, total } + } catch (err) { + console.error("Error fetching document stages only:", err) + return { data: [], pageCount: 0, total: 0 } + } +} + +// 단일 문서의 스테이지 정보 조회 +export async function getDocumentStagesOnlyById(documentId: number) { + try { + const result = await db + .select() + .from(documentStagesOnlyView) + .where(eq(documentStagesOnlyView.documentId, documentId)) + .limit(1) + + return result[0] || null + } catch (err) { + console.error("Error fetching document stages by id:", err) + return null + } +} + +// 특정 계약의 문서 개수 조회 (빠른 카운트) +export async function getDocumentStagesOnlyCount(contractId: number) { + try { + const result = await db + .select({ count: sql<number>`count(*)` }) + .from(documentStagesOnlyView) + .where(eq(documentStagesOnlyView.contractId, contractId)) + + return result[0]?.count ?? 0 + } catch (err) { + console.error("Error counting document stages:", err) + return 0 + } +} + +// 진행률별 문서 통계 조회 +export async function getDocumentProgressStats(contractId: number) { + try { + const result = await db + .select({ + totalDocuments: sql<number>`count(*)`, + completedDocuments: sql<number>`count(case when progress_percentage = 100 then 1 end)`, + inProgressDocuments: sql<number>`count(case when progress_percentage > 0 and progress_percentage < 100 then 1 end)`, + notStartedDocuments: sql<number>`count(case when progress_percentage = 0 then 1 end)`, + overdueDocuments: sql<number>`count(case when is_overdue = true then 1 end)`, + avgProgress: sql<number>`round(avg(progress_percentage), 2)`, + }) + .from(documentStagesOnlyView) + .where(eq(documentStagesOnlyView.contractId, contractId)) + + return result[0] || { + totalDocuments: 0, + completedDocuments: 0, + inProgressDocuments: 0, + notStartedDocuments: 0, + overdueDocuments: 0, + avgProgress: 0, + } + } catch (err) { + console.error("Error fetching document progress stats:", err) + return { + totalDocuments: 0, + completedDocuments: 0, + inProgressDocuments: 0, + notStartedDocuments: 0, + overdueDocuments: 0, + avgProgress: 0, + } + } +} + +// 스테이지별 문서 분포 조회 +export async function getDocumentsByStageStats(contractId: number) { + try { + const result = await db + .select({ + stageName: documentStagesOnlyView.currentStageName, + stageStatus: documentStagesOnlyView.currentStageStatus, + documentCount: sql<number>`count(*)`, + overdueCount: sql<number>`count(case when is_overdue = true then 1 end)`, + }) + .from(documentStagesOnlyView) + .where(eq(documentStagesOnlyView.contractId, contractId)) + .groupBy( + documentStagesOnlyView.currentStageName, + documentStagesOnlyView.currentStageStatus + ) + .orderBy(sql`count(*) desc`) + + return result + } catch (err) { + console.error("Error fetching documents by stage stats:", err) + return [] + } +} diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx new file mode 100644 index 00000000..736a7467 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -0,0 +1,449 @@ +"use client" + +import React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { getDocumentStagesOnly } from "./document-stages-service" +import type { DocumentStagesOnlyView } from "@/db/schema" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + AlertTriangle, + Clock, + TrendingUp, + Target, + Users, + Plus, + FileSpreadsheet +} from "lucide-react" +import { getDocumentStagesColumns } from "./document-stages-columns" +import { ExpandableDataTable } from "@/components/data-table/expandable-data-table" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { DocumentStagesExpandedContent } from "./document-stages-expanded-content" +import { AddDocumentDialog } from "./document-stage-dialogs" +import { EditDocumentDialog } from "./document-stage-dialogs" +import { EditStageDialog } from "./document-stage-dialogs" +import { ExcelImportDialog } from "./document-stage-dialogs" + +interface DocumentStagesTableProps { + promises: Promise<[Awaited<ReturnType<typeof getDocumentStagesOnly>>]> + contractId: number + projectType: "ship" | "plant" +} + +export function DocumentStagesTable({ + promises, + contractId, + projectType, +}: DocumentStagesTableProps) { + const [{ data, pageCount, total }] = React.use(promises) + + console.log(data) + + // 상태 관리 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<DocumentStagesOnlyView> | null>(null) + const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) + const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') + + // 다이얼로그 상태들 + const [addDocumentOpen, setAddDocumentOpen] = React.useState(false) + const [editDocumentOpen, setEditDocumentOpen] = React.useState(false) + const [editStageOpen, setEditStageOpen] = React.useState(false) + const [excelImportOpen, setExcelImportOpen] = React.useState(false) + + // 선택된 항목들 + const [selectedDocument, setSelectedDocument] = React.useState<DocumentStagesOnlyView | null>(null) + const [selectedStageId, setSelectedStageId] = React.useState<number | null>(null) + + // 컬럼 정의 + const columns = React.useMemo( + () => getDocumentStagesColumns({ + setRowAction: (action) => { + setRowAction(action) + if (action) { + setSelectedDocument(action.row.original) + + switch (action.type) { + case "edit_document": + setEditDocumentOpen(true) + break + case "edit_stage": + if (action.meta?.stageId) { + setSelectedStageId(action.meta.stageId) + setEditStageOpen(true) + } + break + case "view": + const rowId = action.row.id + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + break + } + } + }, + projectType + }), + [expandedRows, projectType] + ) + + // 통계 계산 + const stats = React.useMemo(() => { + const totalDocs = data.length + const overdue = data.filter(doc => doc.isOverdue).length + const dueSoon = data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ).length + const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length + const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length + const avgProgress = totalDocs > 0 + ? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) + : 0 + + return { + total: totalDocs, + overdue, + dueSoon, + inProgress, + highPriority, + avgProgress + } + }, [data]) + + // 빠른 필터링 + const filteredData = React.useMemo(() => { + switch (quickFilter) { + case 'overdue': + return data.filter(doc => doc.isOverdue) + case 'due_soon': + return data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ) + case 'in_progress': + return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') + case 'high_priority': + return data.filter(doc => doc.currentStagePriority === 'HIGH') + default: + return data + } + }, [data, quickFilter]) + + // 핸들러 함수들 + const handleNewDocument = () => { + setAddDocumentOpen(true) + } + + const handleExcelImport = () => { + setExcelImportOpen(true) + } + + const handleBulkAction = async (action: string, selectedRows: any[]) => { + try { + if (action === 'bulk_complete') { + const stageIds = selectedRows + .map(row => row.original.currentStageId) + .filter(Boolean) + + if (stageIds.length > 0) { + toast.success(`${stageIds.length}개 스테이지가 완료 처리되었습니다.`) + } + } else if (action === 'bulk_assign') { + toast.info("일괄 담당자 지정 기능은 준비 중입니다.") + } + } catch (error) { + toast.error("일괄 작업 중 오류가 발생했습니다.") + } + } + + const closeAllDialogs = () => { + setAddDocumentOpen(false) + setEditDocumentOpen(false) + setEditStageOpen(false) + setExcelImportOpen(false) + setSelectedDocument(null) + setSelectedStageId(null) + setRowAction(null) + } + + // 필터 필드 정의 + const filterFields: DataTableFilterField<DocumentStagesOnlyView>[] = [ + { + label: "문서번호", + value: "docNumber", + placeholder: "문서번호로 검색...", + }, + { + label: "제목", + value: "title", + placeholder: "제목으로 검색...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<DocumentStagesOnlyView>[] = [ + { + id: "docNumber", + label: "문서번호", + type: "text", + }, + { + id: "title", + label: "문서제목", + type: "text", + }, + { + id: "currentStageStatus", + label: "스테이지 상태", + type: "select", + options: [ + { label: "계획됨", value: "PLANNED" }, + { label: "진행중", value: "IN_PROGRESS" }, + { label: "제출됨", value: "SUBMITTED" }, + { label: "완료됨", value: "COMPLETED" }, + ], + }, + { + id: "currentStagePriority", + label: "우선순위", + type: "select", + options: [ + { label: "높음", value: "HIGH" }, + { label: "보통", value: "MEDIUM" }, + { label: "낮음", value: "LOW" }, + ], + }, + { + id: "isOverdue", + label: "지연 여부", + type: "select", + options: [ + { label: "지연됨", value: "true" }, + { label: "정상", value: "false" }, + ], + }, + { + id: "currentStageAssigneeName", + label: "담당자", + type: "text", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + ] + + const { table } = useDataTable({ + data: filteredData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.documentId), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <div className="space-y-6"> + {/* 통계 대시보드 */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">전체 문서</CardTitle> + <TrendingUp className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total}</div> + <p className="text-xs text-muted-foreground"> + 총 {total}개 문서 + </p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">지연 문서</CardTitle> + <AlertTriangle className="h-4 w-4 text-red-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600">{stats.overdue}</div> + <p className="text-xs text-muted-foreground">즉시 확인 필요</p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">마감 임박</CardTitle> + <Clock className="h-4 w-4 text-orange-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.dueSoon}</div> + <p className="text-xs text-muted-foreground">3일 이내 마감</p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">평균 진행률</CardTitle> + <Target className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.avgProgress}%</div> + <p className="text-xs text-muted-foreground">전체 프로젝트 진행도</p> + </CardContent> + </Card> + </div> + + {/* 빠른 필터 */} + <div className="flex gap-2 overflow-x-auto pb-2"> + <Badge + variant={quickFilter === 'all' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" + onClick={() => setQuickFilter('all')} + > + 전체 ({stats.total}) + </Badge> + <Badge + variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('overdue')} + > + <AlertTriangle className="w-3 h-3 mr-1" /> + 지연 ({stats.overdue}) + </Badge> + <Badge + variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('due_soon')} + > + <Clock className="w-3 h-3 mr-1" /> + 마감임박 ({stats.dueSoon}) + </Badge> + <Badge + variant={quickFilter === 'in_progress' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('in_progress')} + > + <Users className="w-3 h-3 mr-1" /> + 진행중 ({stats.inProgress}) + </Badge> + <Badge + variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('high_priority')} + > + <Target className="w-3 h-3 mr-1" /> + 높은우선순위 ({stats.highPriority}) + </Badge> + </div> + + {/* 메인 테이블 */} + <div className="space-y-4"> + <div className="rounded-md border bg-white overflow-hidden"> + <ExpandableDataTable + table={table} + expandable={true} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + renderExpandedContent={(document) => ( + <DocumentStagesExpandedContent + document={document} + onEditStage={(stageId) => { + setSelectedDocument(document) + setSelectedStageId(stageId) + setEditStageOpen(true) + }} + projectType={projectType} + /> + )} + expandedRowClassName="!p-0" + excludeFromClick={[ + 'actions', + 'select' + ]} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <Button onClick={handleNewDocument} size="sm"> + <Plus className="mr-2 h-4 w-4" /> + 문서 추가 + </Button> + <Button onClick={handleExcelImport} variant="outline" size="sm"> + <FileSpreadsheet className="mr-2 h-4 w-4" /> + 엑셀 가져오기 + </Button> + </div> + </DataTableAdvancedToolbar> + </ExpandableDataTable> + </div> + </div> + + {/* 다이얼로그들 */} + <AddDocumentDialog + open={addDocumentOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setAddDocumentOpen(open) + }} + contractId={contractId} + projectType={projectType} + /> + + <EditDocumentDialog + open={editDocumentOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setEditDocumentOpen(open) + }} + document={selectedDocument} + contractId={contractId} + projectType={projectType} + /> + + <EditStageDialog + open={editStageOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setEditStageOpen(open) + }} + document={selectedDocument} + stageId={selectedStageId} + /> + + <ExcelImportDialog + open={excelImportOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setExcelImportOpen(open) + }} + contractId={contractId} + projectType={projectType} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/plant/excel-import-export.ts b/lib/vendor-document-list/plant/excel-import-export.ts new file mode 100644 index 00000000..3ddb7195 --- /dev/null +++ b/lib/vendor-document-list/plant/excel-import-export.ts @@ -0,0 +1,788 @@ +// excel-import-export.ts +"use client" + +import ExcelJS from 'exceljs' +import { + excelDocumentRowSchema, + excelStageRowSchema, + type ExcelDocumentRow, + type ExcelStageRow, + type ExcelImportResult, + type CreateDocumentInput +} from './document-stage-validations' +import { DocumentStagesOnlyView } from '@/db/schema' + +// ============================================================================= +// 1. 엑셀 템플릿 생성 및 다운로드 +// ============================================================================= + +// 문서 템플릿 생성 +export async function createDocumentTemplate(projectType: "ship" | "plant") { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("문서목록", { + properties: { defaultColWidth: 15 } + }) + + const baseHeaders = [ + "문서번호*", + "문서명*", + "문서종류*", + "PIC", + "발행일", + "설명" + ] + + const plantHeaders = [ + "벤더문서번호", + "벤더명", + "벤더코드" + ] + + const b4Headers = [ + "C구분", + "D구분", + "Degree구분", + "부서구분", + "S구분", + "J구분" + ] + + const headers = [ + ...baseHeaders, + ...(projectType === "plant" ? plantHeaders : []), + ...b4Headers + ] + + // 헤더 행 추가 및 스타일링 + const headerRow = worksheet.addRow(headers) + headerRow.eachCell((cell, colNumber) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' } + } + cell.font = { + color: { argb: 'FFFFFFFF' }, + bold: true, + size: 11 + } + cell.alignment = { + horizontal: 'center', + vertical: 'middle' + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 필수 필드 표시 + if (cell.value && String(cell.value).includes('*')) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE74C3C' } + } + } + }) + + // 샘플 데이터 추가 + const sampleData = projectType === "ship" ? [ + "SH-2024-001", + "기본 설계 도면", + "B3", + "김철수", + new Date("2024-01-15"), + "선박 기본 설계 관련 문서", + "", "", "", "", "", "" // B4 필드들 + ] : [ + "PL-2024-001", + "공정 설계 도면", + "B4", + "이영희", + new Date("2024-01-15"), + "플랜트 공정 설계 관련 문서", + "V-001", // 벤더문서번호 + "삼성엔지니어링", // 벤더명 + "SENG", // 벤더코드 + "C1", "D1", "DEG1", "DEPT1", "S1", "J1" // B4 필드들 + ] + + const sampleRow = worksheet.addRow(sampleData) + sampleRow.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 날짜 형식 설정 + if (cell.value instanceof Date) { + cell.numFmt = 'yyyy-mm-dd' + } + }) + + // 컬럼 너비 자동 조정 + worksheet.columns.forEach((column, index) => { + if (index < 6) { + column.width = headers[index].length + 5 + } else { + column.width = 12 + } + }) + + // 문서종류 드롭다운 설정 + const docTypeCol = headers.indexOf("문서종류*") + 1 + worksheet.dataValidations.add(`${String.fromCharCode(64 + docTypeCol)}2:${String.fromCharCode(64 + docTypeCol)}1000`, { + type: 'list', + allowBlank: false, + formulae: ['"B3,B4,B5"'] + }) + + // Plant 프로젝트의 경우 우선순위 드롭다운 추가 + if (projectType === "plant") { + // 여기에 추가적인 드롭다운들을 설정할 수 있습니다 + } + + return workbook +} + +// 스테이지 템플릿 생성 +export async function createStageTemplate() { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet("스테이지목록", { + properties: { defaultColWidth: 15 } + }) + + const headers = [ + "문서번호*", + "스테이지명*", + "계획일", + "우선순위", + "담당자", + "설명", + "스테이지순서" + ] + + // 헤더 행 추가 및 스타일링 + const headerRow = worksheet.addRow(headers) + headerRow.eachCell((cell, colNumber) => { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF27AE60' } + } + cell.font = { + color: { argb: 'FFFFFFFF' }, + bold: true, + size: 11 + } + cell.alignment = { + horizontal: 'center', + vertical: 'middle' + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 필수 필드 표시 + if (cell.value && String(cell.value).includes('*')) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE74C3C' } + } + } + }) + + // 샘플 데이터 추가 + const sampleData = [ + [ + "SH-2024-001", + "초기 설계 검토", + new Date("2024-02-15"), + "HIGH", + "김철수", + "초기 설계안 검토 및 승인", + 0 + ], + [ + "SH-2024-001", + "상세 설계", + new Date("2024-03-15"), + "MEDIUM", + "이영희", + "상세 설계 작업 수행", + 1 + ] + ] + + sampleData.forEach(rowData => { + const row = worksheet.addRow(rowData) + row.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 날짜 형식 설정 + if (cell.value instanceof Date) { + cell.numFmt = 'yyyy-mm-dd' + } + }) + }) + + // 컬럼 너비 설정 + worksheet.columns = [ + { width: 15 }, // 문서번호 + { width: 20 }, // 스테이지명 + { width: 12 }, // 계획일 + { width: 10 }, // 우선순위 + { width: 15 }, // 담당자 + { width: 30 }, // 설명 + { width: 12 }, // 스테이지순서 + ] + + // 우선순위 드롭다운 설정 + worksheet.dataValidations.add('D2:D1000', { + type: 'list', + allowBlank: true, + formulae: ['"HIGH,MEDIUM,LOW"'] + }) + + return workbook +} + +// 템플릿 다운로드 함수 +export async function downloadTemplate(type: "documents" | "stages", projectType: "ship" | "plant") { + const workbook = await (type === "documents" + ? createDocumentTemplate(projectType) + : createStageTemplate()) + + const filename = type === "documents" + ? `문서_임포트_템플릿_${projectType}.xlsx` + : `스테이지_임포트_템플릿.xlsx` + + // 브라우저에서 다운로드 + 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 = filename + link.click() + + // 메모리 정리 + window.URL.revokeObjectURL(url) +} + +// ============================================================================= +// 2. 엑셀 파일 읽기 및 파싱 +// ============================================================================= + +// 엑셀 파일을 읽어서 JSON으로 변환 +export async function readExcelFile(file: File): Promise<any[]> { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = async (e) => { + try { + const buffer = e.target?.result as ArrayBuffer + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(buffer) + + const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트 + if (!worksheet) { + throw new Error('워크시트를 찾을 수 없습니다') + } + + const jsonData: any[] = [] + + worksheet.eachRow({ includeEmpty: false }, (row, rowNumber) => { + const rowData: any[] = [] + row.eachCell({ includeEmpty: true }, (cell, colNumber) => { + let value = cell.value + + // 날짜 처리 + if (cell.type === ExcelJS.ValueType.Date) { + value = cell.value as Date + } + // 수식 결과값 처리 + else if (cell.type === ExcelJS.ValueType.Formula && cell.result) { + value = cell.result + } + // 하이퍼링크 처리 + else if (cell.type === ExcelJS.ValueType.Hyperlink) { + value = cell.value?.text || cell.value + } + + rowData[colNumber - 1] = value || "" + }) + + jsonData.push(rowData) + }) + + resolve(jsonData) + } catch (error) { + reject(new Error('엑셀 파일을 읽는 중 오류가 발생했습니다: ' + error)) + } + } + + reader.onerror = () => { + reject(new Error('파일을 읽을 수 없습니다')) + } + + reader.readAsArrayBuffer(file) + }) +} + +// 문서 데이터 유효성 검사 및 변환 +export function validateDocumentRows( + rawData: any[], + contractId: number, + projectType: "ship" | "plant" +): { validData: CreateDocumentInput[], errors: any[] } { + if (rawData.length < 2) { + throw new Error('데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다.') + } + + const headers = rawData[0] as string[] + const rows = rawData.slice(1) + + const validData: CreateDocumentInput[] = [] + const errors: any[] = [] + + // 필수 헤더 검사 + const requiredHeaders = ["문서번호", "문서명", "문서종류"] + const missingHeaders = requiredHeaders.filter(h => + !headers.some(header => header.includes(h.replace("*", ""))) + ) + + if (missingHeaders.length > 0) { + throw new Error(`필수 헤더가 누락되었습니다: ${missingHeaders.join(", ")}`) + } + + // 헤더 인덱스 매핑 + const headerMap: Record<string, number> = {} + headers.forEach((header, index) => { + const cleanHeader = header.replace("*", "").trim() + headerMap[cleanHeader] = index + }) + + // 각 행 처리 + rows.forEach((row: any[], rowIndex) => { + try { + // 빈 행 스킵 + if (row.every(cell => !cell || String(cell).trim() === "")) { + return + } + + const rowData: any = { + contractId, + docNumber: String(row[headerMap["문서번호"]] || "").trim(), + title: String(row[headerMap["문서명"]] || "").trim(), + drawingKind: String(row[headerMap["문서종류"]] || "").trim(), + pic: String(row[headerMap["PIC"]] || "").trim() || undefined, + issuedDate: row[headerMap["발행일"]] ? + formatExcelDate(row[headerMap["발행일"]]) : undefined, + } + + // Plant 프로젝트 전용 필드 + if (projectType === "plant") { + rowData.vendorDocNumber = String(row[headerMap["벤더문서번호"]] || "").trim() || undefined + } + + // B4 전용 필드들 + const b4Fields = ["C구분", "D구분", "Degree구분", "부서구분", "S구분", "J구분"] + const b4FieldMap = { + "C구분": "cGbn", + "D구분": "dGbn", + "Degree구분": "degreeGbn", + "부서구분": "deptGbn", + "S구분": "sGbn", + "J구분": "jGbn" + } + + b4Fields.forEach(field => { + if (headerMap[field] !== undefined) { + const value = String(row[headerMap[field]] || "").trim() + if (value) { + rowData[b4FieldMap[field as keyof typeof b4FieldMap]] = value + } + } + }) + + // 유효성 검사 + const validatedData = excelDocumentRowSchema.parse({ + "문서번호": rowData.docNumber, + "문서명": rowData.title, + "문서종류": rowData.drawingKind, + "벤더문서번호": rowData.vendorDocNumber, + "PIC": rowData.pic, + "발행일": rowData.issuedDate, + "C구분": rowData.cGbn, + "D구분": rowData.dGbn, + "Degree구분": rowData.degreeGbn, + "부서구분": rowData.deptGbn, + "S구분": rowData.sGbn, + "J구분": rowData.jGbn, + }) + + // CreateDocumentInput 형태로 변환 + const documentInput: CreateDocumentInput = { + contractId, + docNumber: validatedData["문서번호"], + title: validatedData["문서명"], + drawingKind: validatedData["문서종류"], + vendorDocNumber: validatedData["벤더문서번호"], + pic: validatedData["PIC"], + issuedDate: validatedData["발행일"], + cGbn: validatedData["C구분"], + dGbn: validatedData["D구분"], + degreeGbn: validatedData["Degree구분"], + deptGbn: validatedData["부서구분"], + sGbn: validatedData["S구분"], + jGbn: validatedData["J구분"], + } + + validData.push(documentInput) + + } catch (error) { + errors.push({ + row: rowIndex + 2, // 엑셀 행 번호 (헤더 포함) + message: error instanceof Error ? error.message : "알 수 없는 오류", + data: row + }) + } + }) + + return { validData, errors } +} + +// 엑셀 날짜 형식 변환 +function formatExcelDate(value: any): string | undefined { + if (!value) return undefined + + // ExcelJS에서 Date 객체로 처리된 경우 + if (value instanceof Date) { + return value.toISOString().split('T')[0] + } + + // 이미 문자열 날짜 형식인 경우 + if (typeof value === 'string') { + const dateMatch = value.match(/^\d{4}-\d{2}-\d{2}$/) + if (dateMatch) return value + + // 다른 형식 시도 + const date = new Date(value) + if (!isNaN(date.getTime())) { + return date.toISOString().split('T')[0] + } + } + + // 엑셀 시리얼 날짜인 경우 + if (typeof value === 'number') { + // ExcelJS는 이미 Date 객체로 변환해주므로 이 경우는 드물지만 + // 1900년 1월 1일부터의 일수로 계산 + const excelEpoch = new Date(1900, 0, 1) + const date = new Date(excelEpoch.getTime() + (value - 2) * 24 * 60 * 60 * 1000) + if (!isNaN(date.getTime())) { + return date.toISOString().split('T')[0] + } + } + + return undefined +} + +// ============================================================================= +// 3. 데이터 익스포트 +// ============================================================================= + +// 문서 데이터를 엑셀로 익스포트 +export function exportDocumentsToExcel( + documents: DocumentStagesOnlyView[], + projectType: "ship" | "plant" +) { + const headers = [ + "문서번호", + "문서명", + "문서종류", + "PIC", + "발행일", + "현재스테이지", + "스테이지상태", + "계획일", + "담당자", + "우선순위", + "진행률(%)", + "완료스테이지", + "전체스테이지", + "지연여부", + "남은일수", + "생성일", + "수정일" + ] + + // Plant 프로젝트 전용 헤더 추가 + if (projectType === "plant") { + headers.splice(3, 0, "벤더문서번호", "벤더명", "벤더코드") + } + + const data = documents.map(doc => { + const baseData = [ + doc.docNumber, + doc.title, + doc.drawingKind || "", + doc.pic || "", + doc.issuedDate || "", + doc.currentStageName || "", + getStatusText(doc.currentStageStatus || ""), + doc.currentStagePlanDate || "", + doc.currentStageAssigneeName || "", + getPriorityText(doc.currentStagePriority || ""), + doc.progressPercentage || 0, + doc.completedStages || 0, + doc.totalStages || 0, + doc.isOverdue ? "예" : "아니오", + doc.daysUntilDue || "", + doc.createdAt ? new Date(doc.createdAt).toLocaleDateString() : "", + doc.updatedAt ? new Date(doc.updatedAt).toLocaleDateString() : "" + ] + + // Plant 프로젝트 데이터 추가 + if (projectType === "plant") { + baseData.splice(3, 0, + doc.vendorDocNumber || "", + doc.vendorName || "", + doc.vendorCode || "" + ) + } + + return baseData + }) + + const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) + + // 컬럼 너비 설정 + const colWidths = [ + { wch: 15 }, // 문서번호 + { wch: 30 }, // 문서명 + { wch: 10 }, // 문서종류 + ...(projectType === "plant" ? [ + { wch: 15 }, // 벤더문서번호 + { wch: 20 }, // 벤더명 + { wch: 10 }, // 벤더코드 + ] : []), + { wch: 10 }, // PIC + { wch: 12 }, // 발행일 + { wch: 15 }, // 현재스테이지 + { wch: 10 }, // 스테이지상태 + { wch: 12 }, // 계획일 + { wch: 10 }, // 담당자 + { wch: 8 }, // 우선순위 + { wch: 8 }, // 진행률 + { wch: 8 }, // 완료스테이지 + { wch: 8 }, // 전체스테이지 + { wch: 8 }, // 지연여부 + { wch: 8 }, // 남은일수 + { wch: 12 }, // 생성일 + { wch: 12 }, // 수정일 + ] + + worksheet['!cols'] = colWidths + + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, worksheet, "문서목록") + + const filename = `문서목록_${new Date().toISOString().split('T')[0]}.xlsx` + XLSX.writeFile(workbook, filename) +} + +// 스테이지 상세 데이터를 엑셀로 익스포트 +export function exportStageDetailsToExcel(documents: DocumentStagesOnlyView[]) { + const headers = [ + "문서번호", + "문서명", + "스테이지명", + "스테이지상태", + "스테이지순서", + "계획일", + "담당자", + "우선순위", + "설명", + "노트", + "알림일수" + ] + + const data: any[] = [] + + documents.forEach(doc => { + if (doc.allStages && doc.allStages.length > 0) { + doc.allStages.forEach(stage => { + data.push([ + doc.docNumber, + doc.title, + stage.stageName, + getStatusText(stage.stageStatus), + stage.stageOrder, + stage.planDate || "", + stage.assigneeName || "", + getPriorityText(stage.priority), + stage.description || "", + stage.notes || "", + stage.reminderDays || "" + ]) + }) + } else { + // 스테이지가 없는 문서도 포함 + data.push([ + doc.docNumber, + doc.title, + "", "", "", "", "", "", "", "", "" + ]) + } + }) + + const worksheet = XLSX.utils.aoa_to_sheet([headers, ...data]) + + // 컬럼 너비 설정 + worksheet['!cols'] = [ + { wch: 15 }, // 문서번호 + { wch: 30 }, // 문서명 + { wch: 20 }, // 스테이지명 + { wch: 12 }, // 스테이지상태 + { wch: 8 }, // 스테이지순서 + { wch: 12 }, // 계획일 + { wch: 10 }, // 담당자 + { wch: 8 }, // 우선순위 + { wch: 25 }, // 설명 + { wch: 25 }, // 노트 + { wch: 8 }, // 알림일수 + ] + + const workbook = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(workbook, worksheet, "스테이지상세") + + const filename = `스테이지상세_${new Date().toISOString().split('T')[0]}.xlsx` + XLSX.writeFile(workbook, filename) +} + +// ============================================================================= +// 4. 유틸리티 함수들 +// ============================================================================= + +function getStatusText(status: string): string { + switch (status) { + case 'PLANNED': return '계획됨' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출됨' + case 'UNDER_REVIEW': return '검토중' + case 'APPROVED': return '승인됨' + case 'REJECTED': return '반려됨' + case 'COMPLETED': return '완료됨' + default: return status + } +} + +function getPriorityText(priority: string): string { + switch (priority) { + case 'HIGH': return '높음' + case 'MEDIUM': return '보통' + case 'LOW': return '낮음' + default: return priority + } +} + +// 파일 크기 검증 +export function validateFileSize(file: File, maxSizeMB: number = 10): boolean { + const maxSizeBytes = maxSizeMB * 1024 * 1024 + return file.size <= maxSizeBytes +} + +// 파일 확장자 검증 +export function validateFileExtension(file: File): boolean { + const allowedExtensions = ['.xlsx', '.xls'] + const fileName = file.name.toLowerCase() + return allowedExtensions.some(ext => fileName.endsWith(ext)) +} + +// ExcelJS 워크북의 유효성 검사 +export async function validateExcelWorkbook(file: File): Promise<{ + isValid: boolean + error?: string + worksheetCount?: number + firstWorksheetName?: string +}> { + try { + const buffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(buffer) + + const worksheets = workbook.worksheets + if (worksheets.length === 0) { + return { + isValid: false, + error: '워크시트가 없는 파일입니다' + } + } + + const firstWorksheet = worksheets[0] + if (firstWorksheet.rowCount < 2) { + return { + isValid: false, + error: '데이터가 없습니다. 최소 헤더와 1개 행이 필요합니다' + } + } + + return { + isValid: true, + worksheetCount: worksheets.length, + firstWorksheetName: firstWorksheet.name + } + } catch (error) { + return { + isValid: false, + error: `파일을 읽을 수 없습니다: ${error instanceof Error ? error.message : '알 수 없는 오류'}` + } + } +} + +// 셀 값을 안전하게 문자열로 변환 +export function getCellValueAsString(cell: ExcelJS.Cell): string { + if (!cell.value) return "" + + if (cell.value instanceof Date) { + return cell.value.toISOString().split('T')[0] + } + + if (typeof cell.value === 'object' && 'text' in cell.value) { + return cell.value.text || "" + } + + if (typeof cell.value === 'object' && 'result' in cell.value) { + return String(cell.value.result || "") + } + + return String(cell.value) +} + +// 엑셀 컬럼 인덱스를 문자로 변환 (A, B, C, ... Z, AA, AB, ...) +export 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 +}
\ No newline at end of file |
