diff options
Diffstat (limited to 'lib/vendor-document-list/plant')
6 files changed, 738 insertions, 332 deletions
diff --git a/lib/vendor-document-list/plant/document-stage-dialogs.tsx b/lib/vendor-document-list/plant/document-stage-dialogs.tsx index 732a4bed..726ea101 100644 --- a/lib/vendor-document-list/plant/document-stage-dialogs.tsx +++ b/lib/vendor-document-list/plant/document-stage-dialogs.tsx @@ -3,11 +3,13 @@ import React from "react" import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from "@/components/ui/dialog" import { Sheet, @@ -30,19 +32,36 @@ import { } 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 { Upload, FileSpreadsheet, Calendar, User, Target, Loader2, AlertTriangle, Loader ,Trash} from "lucide-react" import { toast } from "sonner" import { getDocumentNumberTypes, getDocumentNumberTypeConfigs, getComboBoxOptions, getDocumentClasses, + getDocumentClassOptions, createDocument, + updateDocument, + deleteDocuments, updateStage } from "./document-stages-service" +import { type Row } from "@tanstack/react-table" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { useRouter } from "next/navigation" // ============================================================================= -// 1. Add Document Dialog (Updated with fixed header/footer and English text) +// 1. Add Document Dialog // ============================================================================= interface AddDocumentDialogProps { open: boolean @@ -62,14 +81,15 @@ export function AddDocumentDialog({ const [documentClasses, setDocumentClasses] = React.useState<any[]>([]) const [selectedTypeConfigs, setSelectedTypeConfigs] = React.useState<any[]>([]) const [comboBoxOptions, setComboBoxOptions] = React.useState<Record<number, any[]>>({}) + const [documentClassOptions, setDocumentClassOptions] = React.useState<any[]>([]) const [formData, setFormData] = React.useState({ documentNumberTypeId: "", documentClassId: "", title: "", - pic: "", vendorDocNumber: "", - fieldValues: {} as Record<string, string> + fieldValues: {} as Record<string, string>, + planDates: {} as Record<number, string> // optionId -> planDate }) // Load initial data @@ -150,6 +170,35 @@ export function AddDocumentDialog({ }) } + // Handle document class change + const handleDocumentClassChange = async (documentClassId: string) => { + setFormData({ + ...formData, + documentClassId, + planDates: {} + }) + + if (documentClassId) { + const optionsResult = await getDocumentClassOptions(Number(documentClassId)) + if (optionsResult.success) { + setDocumentClassOptions(optionsResult.data) + } + } else { + setDocumentClassOptions([]) + } + } + + // Handle plan date change + const handlePlanDateChange = (optionId: number, date: string) => { + setFormData({ + ...formData, + planDates: { + ...formData.planDates, + [optionId]: date + } + }) + } + // Generate document number preview const generatePreviewDocNumber = () => { if (selectedTypeConfigs.length === 0) return "" @@ -166,12 +215,44 @@ export function AddDocumentDialog({ return preview } + // Check if form is valid for submission + const isFormValid = () => { + // Check basic required fields + if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title.trim()) { + return false + } + + // Check if all required document number components are filled + const requiredConfigs = selectedTypeConfigs.filter(config => config.required) + for (const config of requiredConfigs) { + const fieldKey = `field_${config.sdq}` + const value = formData.fieldValues[fieldKey] + if (!value || !value.trim()) { + return false + } + } + + // Check if document number can be generated + const docNumber = generatePreviewDocNumber() + if (!docNumber || docNumber === "" || docNumber.includes("[value]")) { + return false + } + + return true + } + const handleSubmit = async () => { - if (!formData.documentNumberTypeId || !formData.documentClassId || !formData.title) { + if (!isFormValid()) { toast.error("Please fill in all required fields.") return } + const docNumber = generatePreviewDocNumber() + if (!docNumber) { + toast.error("Cannot generate document number.") + return + } + setIsLoading(true) try { const result = await createDocument({ @@ -179,8 +260,9 @@ export function AddDocumentDialog({ documentNumberTypeId: Number(formData.documentNumberTypeId), documentClassId: Number(formData.documentClassId), title: formData.title, + docNumber: docNumber, // 미리 생성된 문서번호 전송 fieldValues: formData.fieldValues, - pic: formData.pic, + planDates: formData.planDates, vendorDocNumber: formData.vendorDocNumber, }) @@ -203,12 +285,13 @@ export function AddDocumentDialog({ documentNumberTypeId: "", documentClassId: "", title: "", - pic: "", vendorDocNumber: "", - fieldValues: {} + fieldValues: {}, + planDates: {} }) setSelectedTypeConfigs([]) setComboBoxOptions({}) + setDocumentClassOptions([]) } const isPlantProject = projectType === "plant" @@ -317,7 +400,7 @@ export function AddDocumentDialog({ </Label> <Select value={formData.documentClassId} - onValueChange={(value) => setFormData({ ...formData, documentClassId: value })} + onValueChange={handleDocumentClassChange} > <SelectTrigger> <SelectValue placeholder="Select document class" /> @@ -337,6 +420,38 @@ export function AddDocumentDialog({ )} </div> + {/* Document Class Options with Plan Dates */} + {documentClassOptions.length > 0 && ( + <div className="border rounded-lg p-4 bg-green-50/30"> + <Label className="text-sm font-medium text-green-800 mb-3 block"> + Document Class Stages with Plan Dates + </Label> + <div className="grid gap-3"> + {documentClassOptions.map((option) => ( + <div key={option.id} className="grid grid-cols-2 gap-3 items-center"> + <div> + <Label className="text-sm font-medium"> + {option.optionValue} + </Label> + {option.optionCode && ( + <p className="text-xs text-gray-500">Code: {option.optionCode}</p> + )} + </div> + <div className="grid gap-1"> + <Label className="text-xs text-gray-600">Plan Date</Label> + <Input + type="date" + value={formData.planDates[option.id] || ""} + onChange={(e) => handlePlanDateChange(option.id, e.target.value)} + className="text-sm" + /> + </div> + </div> + ))} + </div> + </div> + )} + {/* Document Title */} <div className="grid gap-2"> <Label htmlFor="title"> @@ -351,17 +466,17 @@ export function AddDocumentDialog({ </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> - )} + {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> )} @@ -370,7 +485,7 @@ export function AddDocumentDialog({ <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> Cancel </Button> - <Button onClick={handleSubmit} disabled={isLoading}> + <Button onClick={handleSubmit} disabled={isLoading || !isFormValid()}> {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} Add Document </Button> @@ -381,7 +496,7 @@ export function AddDocumentDialog({ } // ============================================================================= -// 2. Edit Document Dialog (Updated with English text) +// 2. Edit Document Dialog // ============================================================================= interface EditDocumentDialogProps { open: boolean @@ -398,29 +513,77 @@ export function EditDocumentDialog({ contractId, projectType }: EditDocumentDialogProps) { + const [isLoading, setIsLoading] = React.useState(false) const [formData, setFormData] = React.useState({ title: "", - pic: "", vendorDocNumber: "", + stagePlanDates: {} as Record<number, string> // stageId -> planDate }) React.useEffect(() => { if (document) { + // 기본 문서 정보 설정 setFormData({ title: document.title || "", - pic: document.pic || "", vendorDocNumber: document.vendorDocNumber || "", + stagePlanDates: {} }) + + // 현재 스테이지들의 plan date 설정 + if (document.allStages) { + const planDates: Record<number, string> = {} + document.allStages.forEach(stage => { + if (stage.planDate) { + planDates[stage.id] = stage.planDate + } + }) + setFormData(prev => ({ + ...prev, + stagePlanDates: planDates + })) + } } }, [document]) + const handleStagePlanDateChange = (stageId: number, date: string) => { + setFormData({ + ...formData, + stagePlanDates: { + ...formData.stagePlanDates, + [stageId]: date + } + }) + } + + const isFormValid = () => { + return formData.title.trim() !== "" + } + const handleSubmit = async () => { + if (!isFormValid() || !document) { + toast.error("Please fill in all required fields.") + return + } + + setIsLoading(true) try { - // TODO: API call to update document - toast.success("Document updated successfully.") - onOpenChange(false) + const result = await updateDocument({ + documentId: document.id, + title: formData.title, + vendorDocNumber: formData.vendorDocNumber, + stagePlanDates: formData.stagePlanDates, + }) + + if (result.success) { + toast.success("Document updated successfully.") + onOpenChange(false) + } else { + toast.error(result.error || "Error updating document.") + } } catch (error) { toast.error("Error updating document.") + } finally { + setIsLoading(false) } } @@ -428,35 +591,38 @@ export function EditDocumentDialog({ return ( <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="sm:max-w-[500px]"> - <SheetHeader> + <SheetContent className="sm:max-w-[600px] h-full flex flex-col"> + <SheetHeader className="flex-shrink-0"> <SheetTitle>Edit Document</SheetTitle> <SheetDescription> - You can modify the basic information of the document. + You can modify the document information and stage plan dates. </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 className="flex-1 overflow-y-auto pr-2"> + <div className="grid gap-4 py-4"> + {/* Document Number (Read-only) */} + <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> - <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> + {/* Document Title */} + <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"> + {/* Vendor Document Number (Plant project only) */} {isPlantProject && ( <div className="grid gap-2"> <Label htmlFor="edit-vendorDocNumber">Vendor Document Number</Label> @@ -468,23 +634,50 @@ export function EditDocumentDialog({ /> </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> + + {/* Current Document Stages with Plan Dates */} + {document?.allStages && document.allStages.length > 0 && ( + <div className="border rounded-lg p-4 bg-green-50/30"> + <Label className="text-sm font-medium text-green-800 mb-3 block"> + Document Stages - Plan Dates + </Label> + <div className="grid gap-3"> + {document.allStages + .sort((a, b) => (a.stageOrder || 0) - (b.stageOrder || 0)) + .map((stage) => ( + <div key={stage.id} className="grid grid-cols-2 gap-3 items-center"> + <div> + <Label className="text-sm font-medium"> + {stage.stageName} + </Label> + <p className="text-xs text-gray-500"> + Status: {stage.stageStatus} + {stage.actualDate && ` | Completed: ${stage.actualDate}`} + </p> + </div> + <div className="grid gap-1"> + <Label className="text-xs text-gray-600">Plan Date</Label> + <Input + type="date" + value={formData.stagePlanDates[stage.id] || ""} + onChange={(e) => handleStagePlanDateChange(stage.id, e.target.value)} + className="text-sm" + /> + </div> + </div> + ))} + </div> + </div> + )} </div> </div> - <SheetFooter> - <Button variant="outline" onClick={() => onOpenChange(false)}> + <SheetFooter className="flex-shrink-0"> + <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}> Cancel </Button> - <Button onClick={handleSubmit}> + <Button onClick={handleSubmit} disabled={isLoading || !isFormValid()}> + {isLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null} Save Changes </Button> </SheetFooter> @@ -494,7 +687,7 @@ export function EditDocumentDialog({ } // ============================================================================= -// 3. Edit Stage Dialog (Updated with English text) +// 3. Edit Stage Dialog // ============================================================================= interface EditStageDialogProps { open: boolean @@ -687,7 +880,7 @@ export function EditStageDialog({ } // ============================================================================= -// 4. Excel Import Dialog (Updated with English text) +// 4. Excel Import Dialog // ============================================================================= interface ExcelImportDialogProps { open: boolean @@ -767,7 +960,7 @@ export function ExcelImportDialog({ <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>• Optional columns: Vendor Document Number</p> )} <p>• Supported formats: .xlsx, .xls</p> </div> @@ -786,4 +979,129 @@ export function ExcelImportDialog({ </DialogContent> </Dialog> ) -}
\ No newline at end of file +} + +// ============================================================================= +// 5. Delete Documents Confirmation Dialog +// ============================================================================= + +interface DeleteDocumentsDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + documents: Row<DocumentStagesOnlyView>["original"][] + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteDocumentsDialog({ + documents, + showTrigger = true, + onSuccess, + ...props +}: DeleteDocumentsDialogProps) { + + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + const router = useRouter() + + function onDelete() { + startDeleteTransition(async () => { + const { error } = await deleteDocuments({ + ids: documents.map((document) => document.documentId), + }) + + if (error) { + toast.error(error) + return + } + + props.onOpenChange?.(false) + toast.success("Documents deleted") + + router.refresh() + onSuccess?.() + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({documents.length}) + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{documents.length}</span> + {documents.length === 1 ? " document" : " documents"} from our servers. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">Cancel</Button> + </DialogClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + Delete + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="outline" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + Delete ({documents.length}) + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerDescription> + This action cannot be undone. This will permanently delete your{" "} + <span className="font-medium">{documents.length}</span> + {documents.length === 1 ? " document" : " documents"} from our servers. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">Cancel</Button> + </DrawerClose> + <Button + aria-label="Delete selected rows" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + Delete + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/vendor-document-list/plant/document-stage-toolbar.tsx b/lib/vendor-document-list/plant/document-stage-toolbar.tsx new file mode 100644 index 00000000..87b221b7 --- /dev/null +++ b/lib/vendor-document-list/plant/document-stage-toolbar.tsx @@ -0,0 +1,103 @@ +"use client" + +import * as React from "react" +import { type DocumentStagesOnlyView } from "@/db/schema" +import { type Table } from "@tanstack/react-table" +import { Download, Upload, Plus, FileSpreadsheet } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" + +// 다이얼로그 컴포넌트들 import +import { + DeleteDocumentsDialog, + AddDocumentDialog, + ExcelImportDialog +} from "./document-stage-dialogs" + +// 서버 액션 import (필요한 경우) +// import { importDocumentsExcel } from "./document-stages-service" + +interface DocumentsTableToolbarActionsProps { + table: Table<DocumentStagesOnlyView> + contractId: number + projectType: "ship" | "plant" +} + +export function DocumentsTableToolbarActions({ + table, + contractId, + projectType +}: DocumentsTableToolbarActionsProps) { + // 다이얼로그 상태 관리 + const [showAddDialog, setShowAddDialog] = React.useState(false) + const [showExcelImportDialog, setShowExcelImportDialog] = React.useState(false) + + const handleExcelImport = () => { + setShowExcelImportDialog(true) + } + function handleDeleteSuccess() { + // 삭제 성공 후 모든 선택 해제 + table.toggleAllRowsSelected(false) + toast.success("Selected documents deleted successfully") + } + + function handleExport() { + exportTableToExcel(table, { + filename: `documents_contract_${contractId}`, + excludeColumns: ["select", "actions"], // 체크박스와 액션 컬럼 제외 + }) + } + + return ( + <div className="flex items-center gap-2"> + {/* 1) 선택된 문서가 있으면 삭제 다이얼로그 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteDocumentsDialog + documents={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + + {/* 2) 새 문서 추가 다이얼로그 */} + <Button onClick={() => setShowAddDialog(true)} size="sm" className="gap-2"> + <Plus className="h-4 w-4" /> + <span className="hidden sm:inline">Add Document</span> + </Button> + + <AddDocumentDialog + open={showAddDialog} + onOpenChange={setShowAddDialog} + contractId={contractId} + projectType={projectType} + /> + + <Button onClick={handleExcelImport} variant="outline" size="sm"> + <FileSpreadsheet className="mr-2 h-4 w-4" /> + Excel Import + </Button> + + <ExcelImportDialog + open={showExcelImportDialog} + onOpenChange={setShowExcelImportDialog} + contractId={contractId} + projectType={projectType} + /> + + {/* 4) Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleExport} + className="gap-2" + > + <Download className="h-4 w-4" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + ) +} diff --git a/lib/vendor-document-list/plant/document-stages-columns.tsx b/lib/vendor-document-list/plant/document-stages-columns.tsx index d39af4e8..7456c2aa 100644 --- a/lib/vendor-document-list/plant/document-stages-columns.tsx +++ b/lib/vendor-document-list/plant/document-stages-columns.tsx @@ -328,7 +328,7 @@ export function getDocumentStagesColumns({ return ( <div className="flex items-center gap-2"> - <span className="text-sm">{formatDate(doc.currentStagePlanDate, 'MM/dd')}</span> + <span className="text-sm">{formatDate(doc.currentStagePlanDate)}</span> <DueDateInfo daysUntilDue={doc.daysUntilDue} isOverdue={doc.isOverdue || false} @@ -336,7 +336,7 @@ export function getDocumentStagesColumns({ </div> ) }, - size: 120, + size: 150, enableResizing: true, meta: { excelHeader: "Plan Date" @@ -425,7 +425,7 @@ export function getDocumentStagesColumns({ key: "edit_document", label: "Edit Document", icon: Edit, - action: () => setRowAction({ row, type: "edit_document" }), + action: () => setRowAction({ row, type: "update" }), show: true } ] @@ -502,9 +502,9 @@ export function getDocumentStagesColumns({ > <action.icon className="mr-2 h-3 w-3" /> <span className="text-xs">{action.label}</span> - {action.shortcut && ( + {/* {action.shortcut && ( <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> - )} + )} */} </DropdownMenuItem> ))} </> diff --git a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx index 2f6b637c..070d6904 100644 --- a/lib/vendor-document-list/plant/document-stages-expanded-content.tsx +++ b/lib/vendor-document-list/plant/document-stages-expanded-content.tsx @@ -106,13 +106,13 @@ export function DocumentStagesExpandedContent({ {planDate && ( <div className="flex items-center gap-1"> <Calendar className="h-3 w-3" /> - <span>계획: {formatDate(planDate.toISOString(), 'MM/dd')}</span> + <span>계획: {formatDate(planDate.toISOString())}</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> + <span>실적: {formatDate(actualDate.toISOString())}</span> </div> )} </div> diff --git a/lib/vendor-document-list/plant/document-stages-service.ts b/lib/vendor-document-list/plant/document-stages-service.ts index 2fd20fa4..108b5869 100644 --- a/lib/vendor-document-list/plant/document-stages-service.ts +++ b/lib/vendor-document-list/plant/document-stages-service.ts @@ -4,8 +4,8 @@ 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 { codeGroups, comboBoxSettings, contracts, documentClassOptions, documentClasses, documentNumberTypeConfigs, documentNumberTypes, documentStagesOnlyView, documents, issueStages } from "@/db/schema" +import { and, eq, asc, desc, sql, inArray, max, ne ,or, ilike} from "drizzle-orm" import { createDocumentSchema, updateDocumentSchema, @@ -31,163 +31,57 @@ import { filterColumns } from "@/lib/filter-columns" import { GetEnhancedDocumentsSchema } from "../enhanced-document-service" import { countDocumentStagesOnly, selectDocumentStagesOnly } from "../repository" -// ============================================================================= -// 1. 문서 관련 액션들 -// ============================================================================= +interface UpdateDocumentData { + documentId: number + title: string + vendorDocNumber?: string + stagePlanDates: Record<number, string> // stageId -> planDate +} -// 문서 생성 -// 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() - +export async function updateDocument(data: UpdateDocumentData) { 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("이미 존재하는 문서번호입니다") - } - } - - // 문서 업데이트 + // 1. 문서 기본 정보 업데이트 const [updatedDocument] = await db .update(documents) .set({ - ...validatedData, + title: data.title, + vendorDocNumber: data.vendorDocNumber || null, updatedAt: new Date(), }) - .where(eq(documents.id, validatedData.id)) + .where(eq(documents.id, data.documentId)) .returning() - - // 캐시 무효화 - revalidateTag(`documents-${existingDoc.contractId}`) - revalidatePath(`/contracts/${existingDoc.contractId}/documents`) + + if (!updatedDocument) { + return { success: false, error: "문서를 찾을 수 없습니다." } + } + + // 2. 스테이지들의 plan date 업데이트 + const stageUpdatePromises = Object.entries(data.stagePlanDates).map(([stageId, planDate]) => { + return db + .update(issueStages) + .set({ + planDate: planDate || null, + updatedAt: new Date(), + }) + .where(eq(issueStages.id, Number(stageId))) + }) + + await Promise.all(stageUpdatePromises) + + // 3. 캐시 무효화 + revalidatePath(`/contracts/${updatedDocument.contractId}/documents`) return { success: true, - data: updatedDocument, - message: "문서가 성공적으로 수정되었습니다" + data: updatedDocument } - } catch (error) { - console.error("Error updating document:", error) - return { - success: false, - error: error instanceof Error ? error.message : "문서 수정 중 오류가 발생했습니다" - } + console.error("문서 업데이트 실패:", error) + return { success: false, error: "문서 업데이트 중 오류가 발생했습니다." } } } - // 문서 삭제 (소프트 삭제) export async function deleteDocument(input: { id: number }) { noStore() @@ -240,6 +134,82 @@ export async function deleteDocument(input: { id: number }) { } } +interface DeleteDocumentsData { + ids: number[] +} + +export async function deleteDocuments(data: DeleteDocumentsData) { + try { + if (data.ids.length === 0) { + return { success: false, error: "삭제할 문서가 선택되지 않았습니다." } + } + + /* 1. 요청한 문서가 존재하는지 확인 ------------------------------------ */ + const existingDocs = await db + .select({ id: documents.id, docNumber: documents.docNumber }) + .from(documents) + .where(and( + inArray(documents.id, data.ids), + )) + + if (existingDocs.length === 0) { + return { success: false, error: "삭제할 문서를 찾을 수 없습니다." } + } + + if (existingDocs.length !== data.ids.length) { + return { + success: false, + error: "일부 문서를 찾을 수 없거나 이미 삭제되었습니다." + } + } + + /* 2. 연관 스테이지 건수 파악(로그·메시지용) --------------------------- */ + const relatedStages = await db + .select({ documentId: issueStages.documentId }) + .from(issueStages) + .where(inArray(issueStages.documentId, data.ids)) + + const stagesToDelete = relatedStages.length + + /* 3. 연관 스테이지 삭제 --------------------------------------------- */ + // ─> FK에 ON DELETE CASCADE 가 있다면 생략 가능. + if (stagesToDelete > 0) { + await db + .delete(issueStages) + .where(inArray(issueStages.documentId, data.ids)) + } + + /* 4. 문서 하드 삭제 --------------------------------------------------- */ + const deletedDocs = await db + .delete(documents) + .where(and( + inArray(documents.id, data.ids), + )) + .returning({ id: documents.id, docNumber: documents.docNumber }) + + /* 5. 캐시 무효화 ------------------------------------------------------ */ + + return { + success: true, + message: `${deletedDocs.length}개의 문서가 완전히 삭제되었습니다.`, + data: { + deletedCount: deletedDocs.length, + deletedDocuments: deletedDocs, + stagesDeletedCount: stagesToDelete + } + } + } catch (error) { + console.error("문서 삭제 실패:", error) + return { + success: false, + error: error instanceof Error + ? error.message + : "문서 삭제 중 오류가 발생했습니다." + } + } +} + + // ============================================================================= // 2. 스테이지 관련 액션들 // ============================================================================= @@ -840,64 +810,105 @@ export async function generateDocumentNumber(configs: any[], values: Record<stri return docNumber.replace(/-$/, "") // 마지막 하이픈 제거 } -// 문서 생성 -export async function createDocument(data: { +interface CreateDocumentData { contractId: number documentNumberTypeId: number documentClassId: number title: string + docNumber: string fieldValues: Record<string, string> + planDates: Record<number, string> // optionId -> planDate pic?: string vendorDocNumber?: string -}) { +} + +// 문서 생성 +export async function createDocument(data: CreateDocumentData) { try { - // 1. 문서번호 타입 설정 조회 - const configsResult = await getDocumentNumberTypeConfigs(data.documentNumberTypeId) + /* ──────────────────────────────── 0. 계약 확인 & projectId 가져오기 ─────────────────────────────── */ + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, data.contractId), + columns: { + id: true, + projectId: true, + }, + }) + + if (!contract) { + return { success: false, error: "유효하지 않은 계약(ID)입니다." } + } + const { projectId } = contract + + /* ──────────────────────────────── 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({ + /* ──────────────────────────────── 3. 문서 레코드 삽입 ─────────────────────────────── */ + const insertData = { + // 필수 + projectId, // ★ 새로 추가 contractId: data.contractId, - docNumber: documentNumber, + docNumber: data.docNumber, title: data.title, - documentClassId: data.documentClassId, - pic: data.pic, - vendorDocNumber: data.vendorDocNumber, - }).returning() - */ + status: "ACTIVE" as const, + createdAt: new Date(), + updatedAt: new Date(), - // 4. 문서 클래스의 옵션들을 스테이지로 자동 생성 - const stageOptionsResult = await getDocumentClassOptions(data.documentClassId) + // 선택 + pic: data.pic ?? null, + vendorDocNumber: data.vendorDocNumber ?? null, + + } + + const [document] = await db + .insert(documents) + .values(insertData) + .onConflictDoNothing({ + // ★ 유니크 키가 projectId 기반이라면 target 도 같이 변경 + target: [ + documents.projectId, + documents.docNumber, + documents.status, + ], + }) + .returning() + + if (!document) { + return { + success: false, + error: "같은 프로젝트·문서번호·상태의 문서가 이미 존재합니다.", + } + } + + /* ──────────────────────────────── 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', - // 기본값들... + const now = new Date() + const stageInserts = stageOptionsResult.data.map((opt, idx) => ({ + documentId: document.id, + stageName: opt.optionValue, + stageOrder: opt.sortOrder ?? idx + 1, + stageStatus: "PLANNED" as const, + planDate: data.planDates[opt.id] ?? null, + createdAt: now, + updatedAt: now, })) - - await db.insert(documentStages).values(stageInserts) - */ + await db.insert(issueStages).values(stageInserts) } + /* ──────────────────────────────── 5. 캐시 무효화 및 응답 ─────────────────────────────── */ revalidatePath(`/contracts/${data.contractId}/documents`) - - return { - success: true, - data: { - documentNumber, - // document - } + + return { + success: true, + data: { document }, } } catch (error) { console.error("문서 생성 실패:", error) @@ -905,37 +916,6 @@ export async function createDocument(data: { } } -// 스테이지 업데이트 -// 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, diff --git a/lib/vendor-document-list/plant/document-stages-table.tsx b/lib/vendor-document-list/plant/document-stages-table.tsx index 736a7467..7d41277e 100644 --- a/lib/vendor-document-list/plant/document-stages-table.tsx +++ b/lib/vendor-document-list/plant/document-stages-table.tsx @@ -13,11 +13,11 @@ 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, +import { + AlertTriangle, + Clock, + TrendingUp, + Target, Users, Plus, FileSpreadsheet @@ -27,10 +27,11 @@ import { ExpandableDataTable } from "@/components/data-table/expandable-data-tab import { toast } from "sonner" import { Button } from "@/components/ui/button" import { DocumentStagesExpandedContent } from "./document-stages-expanded-content" -import { AddDocumentDialog } from "./document-stage-dialogs" +import { AddDocumentDialog, DeleteDocumentsDialog } from "./document-stage-dialogs" import { EditDocumentDialog } from "./document-stage-dialogs" import { EditStageDialog } from "./document-stage-dialogs" import { ExcelImportDialog } from "./document-stage-dialogs" +import { DocumentsTableToolbarActions } from "./document-stage-toolbar" interface DocumentStagesTableProps { promises: Promise<[Awaited<ReturnType<typeof getDocumentStagesOnly>>]> @@ -45,33 +46,32 @@ export function DocumentStagesTable({ }: 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({ + () => getDocumentStagesColumns({ setRowAction: (action) => { setRowAction(action) if (action) { setSelectedDocument(action.row.original) - + switch (action.type) { - case "edit_document": + case "update": setEditDocumentOpen(true) break case "edit_stage": @@ -102,24 +102,24 @@ export function DocumentStagesTable({ 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 && + 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 + 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 + + return { + total: totalDocs, + overdue, + dueSoon, + inProgress, + highPriority, + avgProgress } }, [data]) @@ -129,9 +129,9 @@ export function DocumentStagesTable({ case 'overdue': return data.filter(doc => doc.isOverdue) case 'due_soon': - return data.filter(doc => - doc.daysUntilDue !== null && - doc.daysUntilDue >= 0 && + return data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && doc.daysUntilDue <= 3 ) case 'in_progress': @@ -158,7 +158,7 @@ export function DocumentStagesTable({ const stageIds = selectedRows .map(row => row.original.currentStageId) .filter(Boolean) - + if (stageIds.length > 0) { toast.success(`${stageIds.length}개 스테이지가 완료 처리되었습니다.`) } @@ -189,7 +189,7 @@ export function DocumentStagesTable({ }, { label: "제목", - value: "title", + value: "title", placeholder: "제목으로 검색...", }, ] @@ -280,7 +280,7 @@ export function DocumentStagesTable({ </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> @@ -291,7 +291,7 @@ export function DocumentStagesTable({ <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> @@ -302,7 +302,7 @@ export function DocumentStagesTable({ <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> @@ -317,14 +317,14 @@ export function DocumentStagesTable({ {/* 빠른 필터 */} <div className="flex gap-2 overflow-x-auto pb-2"> - <Badge + <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 + <Badge variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" onClick={() => setQuickFilter('overdue')} @@ -332,7 +332,7 @@ export function DocumentStagesTable({ <AlertTriangle className="w-3 h-3 mr-1" /> 지연 ({stats.overdue}) </Badge> - <Badge + <Badge variant={quickFilter === 'due_soon' ? 'default' : 'outline'} className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" onClick={() => setQuickFilter('due_soon')} @@ -340,7 +340,7 @@ export function DocumentStagesTable({ <Clock className="w-3 h-3 mr-1" /> 마감임박 ({stats.dueSoon}) </Badge> - <Badge + <Badge variant={quickFilter === 'in_progress' ? 'default' : 'outline'} className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" onClick={() => setQuickFilter('in_progress')} @@ -348,7 +348,7 @@ export function DocumentStagesTable({ <Users className="w-3 h-3 mr-1" /> 진행중 ({stats.inProgress}) </Badge> - <Badge + <Badge variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" onClick={() => setQuickFilter('high_priority')} @@ -361,13 +361,13 @@ export function DocumentStagesTable({ {/* 메인 테이블 */} <div className="space-y-4"> <div className="rounded-md border bg-white overflow-hidden"> - <ExpandableDataTable + <ExpandableDataTable table={table} expandable={true} expandedRows={expandedRows} setExpandedRows={setExpandedRows} renderExpandedContent={(document) => ( - <DocumentStagesExpandedContent + <DocumentStagesExpandedContent document={document} onEditStage={(stageId) => { setSelectedDocument(document) @@ -379,8 +379,8 @@ export function DocumentStagesTable({ )} expandedRowClassName="!p-0" excludeFromClick={[ - 'actions', - 'select' + 'actions', + 'select' ]} > <DataTableAdvancedToolbar @@ -388,16 +388,11 @@ export function DocumentStagesTable({ 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> + <DocumentsTableToolbarActions + table={table} + contractId={contractId} + projectType={projectType} + /> </DataTableAdvancedToolbar> </ExpandableDataTable> </div> @@ -444,6 +439,16 @@ export function DocumentStagesTable({ contractId={contractId} projectType={projectType} /> + + <DeleteDocumentsDialog + open={rowAction?.type === "delete"} + onOpenChange={() => setRowAction(null)} + showTrigger={false} + documents={rowAction?.row.original ? [rowAction?.row.original] : []} // 전체 문서 배열 + onSuccess={() => rowAction?.row.toggleSelected(false)} + /> + + </div> ) }
\ No newline at end of file |
