diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:32:31 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-05-28 00:32:31 +0000 |
| commit | 20800b214145ee6056f94ca18fa1054f145eb977 (patch) | |
| tree | b5c8b27febe5b126e6d9ece115ea05eace33a020 /lib/vendor-document-list/table/enhanced-doc-table-columns.tsx | |
| parent | e1344a5da1aeef8fbf0f33e1dfd553078c064ccc (diff) | |
(대표님) lib 파트 커밋
Diffstat (limited to 'lib/vendor-document-list/table/enhanced-doc-table-columns.tsx')
| -rw-r--r-- | lib/vendor-document-list/table/enhanced-doc-table-columns.tsx | 612 |
1 files changed, 612 insertions, 0 deletions
diff --git a/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx new file mode 100644 index 00000000..534a80a0 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx @@ -0,0 +1,612 @@ +// updated-enhanced-doc-table-columns.tsx +"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 { EnhancedDocumentsView } from "@/db/schema/vendorDocu" +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, + Upload, + Calendar, + User, + FileText, + Eye, + Edit, + Trash2 +} from "lucide-react" +import { cn } from "@/lib/utils" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedDocumentsView> | null>> + projectType: string | null +} + +// 유틸리티 함수들 +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 '계획됨' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출됨' + case 'UNDER_REVIEW': return '검토중' + case 'APPROVED': return '승인됨' + case 'REJECTED': return '반려됨' + case 'COMPLETED': return '완료됨' + default: return status + } +} + +const getPriorityText = (priority: string) => { + switch (priority) { + case 'HIGH': return '높음' + case 'MEDIUM': return '보통' + case 'LOW': return '낮음' + default: return priority + } +} + +// 마감일 정보 컴포넌트 +const DueDateInfo = ({ + daysUntilDue, + isOverdue, + className = "" +}: { + daysUntilDue: number | null + isOverdue: boolean + className?: string +}) => { + if (isOverdue && daysUntilDue !== null && daysUntilDue < 0) { + return ( + <div className={cn("flex items-center gap-1 text-red-600", className)}> + <AlertTriangle className="w-4 h-4" /> + <span className="text-sm font-medium">{Math.abs(daysUntilDue)}일 지연</span> + </div> + ) + } + + if (daysUntilDue === 0) { + return ( + <div className={cn("flex items-center gap-1 text-orange-600", className)}> + <Clock className="w-4 h-4" /> + <span className="text-sm font-medium">오늘 마감</span> + </div> + ) + } + + if (daysUntilDue && daysUntilDue > 0 && daysUntilDue <= 3) { + return ( + <div className={cn("flex items-center gap-1 text-orange-600", className)}> + <Clock className="w-4 h-4" /> + <span className="text-sm font-medium">{daysUntilDue}일 남음</span> + </div> + ) + } + + if (daysUntilDue && daysUntilDue > 0) { + return ( + <div className={cn("flex items-center gap-1 text-gray-600", className)}> + <Calendar className="w-4 h-4" /> + <span className="text-sm">{daysUntilDue}일 남음</span> + </div> + ) + } + + return ( + <div className={cn("flex items-center gap-1 text-green-600", className)}> + <CheckCircle className="w-4 h-4" /> + <span className="text-sm">완료</span> + </div> + ) +} + +export function getUpdatedEnhancedColumns({ + setRowAction, + projectType +}: GetColumnsProps): ColumnDef<EnhancedDocumentsView>[] { + return [ + // 체크박스 선택 + { + 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: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="문서번호" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex flex-col gap-1 items-start"> {/* ✅ items-start 추가 */} + <span className="font-mono text-sm font-medium">{doc.docNumber}</span> + {/* {doc.currentStagePriority && ( + <Badge variant={getPriorityColor(doc.currentStagePriority)} className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" > + {getPriorityText(doc.currentStagePriority)} + </Badge> + )} */} + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "문서번호" + }, + }, + + // 문서명 + 담당자 + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="문서명" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="min-w-0 flex-1"> + <div className="font-medium text-gray-900 truncate" title={doc.title}> + {doc.title} + </div> + <div className="flex items-center gap-2 text-sm text-gray-500 mt-1"> + {doc.pic && ( + <span className="text-xs bg-gray-100 px-2 py-0.5 rounded"> + PIC: {doc.pic} + </span> + )} + {doc.currentStageAssigneeName && ( + <div className="flex items-center gap-1"> + <User className="w-3 h-3" /> + <span>{doc.currentStageAssigneeName}</span> + </div> + )} + </div> + </div> + ) + }, + size: 250, + enableResizing: true, + meta: { + excelHeader: "문서명" + }, + }, + + // 현재 스테이지 + { + accessorKey: "currentStageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="현재 스테이지" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStageName) return <span className="text-gray-400">-</span> + + return ( + <div className="flex flex-col gap-1 items-start"> + <span className="text-sm font-medium">{doc.currentStageName}</span> + <Badge + variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} + className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" + > + {getStatusText(doc.currentStageStatus || '')} + </Badge> + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "현재 스테이지" + }, + }, + + // 일정 정보 + { + accessorKey: "currentStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="일정" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStagePlanDate) return <span className="text-gray-400">-</span> + + return ( + <div className="flex flex-col gap-1"> + <div className="text-sm"> + <span className="text-gray-500">계획: </span> + <span>{formatDate(doc.currentStagePlanDate)}</span> + </div> + {doc.currentStageActualDate && ( + <div className="text-sm"> + <span className="text-gray-500">실제: </span> + <span>{formatDate(doc.currentStageActualDate)}</span> + </div> + )} + <DueDateInfo + daysUntilDue={doc.daysUntilDue} + isOverdue={doc.isOverdue || false} + /> + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "계획일" + }, + }, + + // 진행률 + { + accessorKey: "progressPercentage", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="진행률" /> + ), + 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 flex-col gap-2"> + <div className="flex items-center gap-2"> + <Progress value={progress} className="flex-1" /> + <span className="text-sm font-medium text-gray-600 min-w-[3rem]"> + {progress}% + </span> + </div> + <span className="text-xs text-gray-500"> + {completed} / {total} 스테이지 + </span> + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "진행률" + }, + }, + + // 최신 리비전 + { + accessorKey: "latestRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최신 리비전" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.latestRevision) return <span className="text-gray-400">없음</span> + + return ( + <div className="flex flex-col gap-1 items-start"> + <span className="font-mono text-sm font-medium">{doc.latestRevision}</span> + {/* <div className="text-xs text-gray-500">{doc.latestRevisionUploaderName}</div> */} + {doc.latestRevisionStatus && ( + <Badge variant={getStatusColor(doc.latestRevisionStatus)} className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" > + {getStatusText(doc.latestRevisionStatus)} + </Badge> + )} + {doc.latestSubmittedDate && ( + <div className="text-xs text-gray-500"> + {formatDate(doc.latestSubmittedDate)} + </div> + )} + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "최신 리비전" + }, + }, + + // 업데이트 일시 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업데이트" /> + ), + cell: ({ cell }) => ( + <span className="text-sm text-gray-600"> + {formatDateTime(cell.getValue() as Date)} + </span> + ), + size: 140, + enableResizing: true, + meta: { + excelHeader: "업데이트" + }, + }, + + // 액션 메뉴 + // 액션 메뉴 + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const doc = row.original + const canSubmit = doc.currentStageStatus === 'IN_PROGRESS' + const canApprove = doc.currentStageStatus === 'SUBMITTED' + const isPlantProject = projectType === "plant" + + // 메뉴 아이템들을 그룹별로 정의 + const viewActions = [ + { + key: "view", + label: "상세보기", + icon: Eye, + action: () => setRowAction({ row, type: "view" }), + show: true + } + ] + + const editActions = [ + { + key: "update", + label: "편집", + icon: Edit, + action: () => setRowAction({ row, type: "update" }), + show: isPlantProject + } + ] + + const fileActions = [ + { + key: "upload", + label: "리비전 업로드", + icon: Upload, + action: () => setRowAction({ row, type: "upload" }), + show: canSubmit + } + ] + + const dangerActions = [ + { + key: "delete", + label: "삭제", + icon: Trash2, + action: () => setRowAction({ row, type: "delete" }), + show: isPlantProject, + className: "text-red-600", + shortcut: "⌘⌫" + } + ] + + // 각 그룹에서 표시될 아이템이 있는지 확인 + const hasEditActions = editActions.some(action => action.show) + const hasFileActions = fileActions.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-7 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + {/* 기본 액션 그룹 */} + {viewActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + + {/* 편집 액션 그룹 */} + {hasEditActions && ( + <> + <DropdownMenuSeparator /> + {editActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + + {/* 파일 액션 그룹 */} + {hasFileActions && ( + <> + <DropdownMenuSeparator /> + {fileActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {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-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +} + +// 확장된 행 컨텐츠 컴포넌트 (업데이트된 버전) +export const UpdatedExpandedRowContent = ({ + document +}: { + document: EnhancedDocumentsView +}) => { + if (!document.allStages || document.allStages.length === 0) { + return ( + <div className="p-4 text-sm text-gray-500 italic"> + 스테이지 정보가 없습니다. + </div> + ) + } + + return ( + <div className="p-4 w-1/2"> + <h4 className="font-medium mb-3 flex items-center gap-2"> + <FileText className="w-4 h-4" /> + 전체 스테이지 현황 + </h4> + + <div className="grid gap-3"> + {document.allStages.map((stage, index) => ( + <div key={stage.id} className="flex items-center justify-between p-3 bg-white rounded-lg border"> + <div className="flex items-center gap-3"> + <div className="flex items-center gap-2"> + <div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium"> + {stage.stageOrder || index + 1} + </div> + <div className={cn( + "w-3 h-3 rounded-full", + stage.stageStatus === 'COMPLETED' ? 'bg-green-500' : + stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : + stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : + 'bg-gray-300' + )} /> + </div> + + <div> + <div className="font-medium text-sm">{stage.stageName}</div> + {stage.assigneeName && ( + <div className="text-xs text-gray-500 flex items-center gap-1 mt-1"> + <User className="w-3 h-3" /> + {stage.assigneeName} + </div> + )} + </div> + </div> + + <div className="flex items-center gap-4 text-sm"> + <div> + <span className="text-gray-500">계획: </span> + <span>{formatDate(stage.planDate)}</span> + </div> + {stage.actualDate && ( + <div> + <span className="text-gray-500">완료: </span> + <span>{formatDate(stage.actualDate)}</span> + </div> + )} + + <div className="flex items-center gap-2"> + <Badge variant={getPriorityColor(stage.priority)} className="text-xs"> + {getPriorityText(stage.priority)} + </Badge> + <Badge variant={getStatusColor(stage.stageStatus)} className="text-xs"> + {getStatusText(stage.stageStatus)} + </Badge> + </div> + </div> + </div> + ))} + </div> + </div> + ) +}
\ No newline at end of file |
