summaryrefslogtreecommitdiff
path: root/lib/vendor-document-list/plant
diff options
context:
space:
mode:
Diffstat (limited to 'lib/vendor-document-list/plant')
-rw-r--r--lib/vendor-document-list/plant/document-stage-actions.ts0
-rw-r--r--lib/vendor-document-list/plant/document-stage-dialogs.tsx789
-rw-r--r--lib/vendor-document-list/plant/document-stage-validations.ts339
-rw-r--r--lib/vendor-document-list/plant/document-stages-columns.tsx521
-rw-r--r--lib/vendor-document-list/plant/document-stages-expanded-content.tsx136
-rw-r--r--lib/vendor-document-list/plant/document-stages-service.ts1097
-rw-r--r--lib/vendor-document-list/plant/document-stages-table.tsx449
-rw-r--r--lib/vendor-document-list/plant/excel-import-export.ts788
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