| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { ProjectData } from '@/types/project'; |
| import { |
| groupProjectsByHierarchy, |
| isDateStale, |
| formatDateShort, |
| ProjectGroup, |
| ProductGroup, |
| MoldItem |
| } from '@/lib/hierarchyUtils'; |
| import { ChevronDown, ChevronRight, Clock, AlertCircle, CheckCircle2 } from 'lucide-react'; |
| import { useState } from 'react'; |
|
|
| interface ProjectHierarchyTableProps { |
| projects: ProjectData[]; |
| } |
|
|
| export default function ProjectHierarchyTable({ projects }: ProjectHierarchyTableProps) { |
| const hierarchicalData = groupProjectsByHierarchy(projects); |
| |
| |
| const [collapsedProjects, setCollapsedProjects] = useState<Set<string>>(new Set()); |
| |
| const toggleProject = (projectName: string) => { |
| setCollapsedProjects(prev => { |
| const newSet = new Set(prev); |
| if (newSet.has(projectName)) { |
| newSet.delete(projectName); |
| } else { |
| newSet.add(projectName); |
| } |
| return newSet; |
| }); |
| }; |
| |
| return ( |
| <div |
| className="relative z-0 w-full bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden mb-8" |
| style={{ overflowAnchor: 'none' }} |
| > |
| {/* Header */} |
| <div className="bg-gradient-to-r from-slate-700 to-slate-600 px-6 py-4 border-b border-slate-500"> |
| <h2 className="text-xl font-bold text-white tracking-tight"> |
| 项目整体概览 / Project Overview |
| </h2> |
| <p className="text-sm text-slate-300 mt-1"> |
| 按项目和产品分组的宏观视图 · {projects.length} 个模具 |
| </p> |
| </div> |
| |
| {/* Table Container - No horizontal scroll, responsive reflow */} |
| <div> |
| {hierarchicalData.map(projectGroup => ( |
| <ProjectGroupRow |
| key={projectGroup.projectName} |
| projectGroup={projectGroup} |
| isCollapsed={collapsedProjects.has(projectGroup.projectName)} |
| onToggle={() => toggleProject(projectGroup.projectName)} |
| /> |
| ))} |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function ProjectGroupRow({ |
| projectGroup, |
| isCollapsed, |
| onToggle |
| }: { |
| projectGroup: ProjectGroup; |
| isCollapsed: boolean; |
| onToggle: () => void; |
| }) { |
| const totalMolds = projectGroup.products.reduce((sum, p) => sum + p.molds.length, 0); |
| |
| return ( |
| <div className="border-b border-slate-200 last:border-b-0"> |
| {/* Project Header - Clickable to collapse */} |
| <button |
| onClick={onToggle} |
| className="w-full bg-slate-700 hover:bg-slate-600 transition-colors px-6 py-3 flex items-center justify-between group" |
| > |
| <div className="flex items-center gap-3"> |
| {isCollapsed ? ( |
| <ChevronRight className="w-5 h-5 text-slate-300" /> |
| ) : ( |
| <ChevronDown className="w-5 h-5 text-slate-300" /> |
| )} |
| <span className="text-base font-bold text-white"> |
| {projectGroup.projectName} |
| </span> |
| <span className="text-xs text-slate-400 font-medium"> |
| {projectGroup.products.length} 产品 · {totalMolds} 模具 |
| </span> |
| </div> |
| </button> |
| |
| {/* Products (Level 2) - Only show if not collapsed */} |
| {!isCollapsed && projectGroup.products.map(productGroup => ( |
| <ProductGroupRow |
| key={productGroup.productName} |
| productGroup={productGroup} |
| /> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
| function ProductGroupRow({ productGroup }: { productGroup: ProductGroup }) { |
| return ( |
| <div className="bg-slate-50"> |
| {/* Product Header */} |
| <div className="bg-slate-100 px-6 py-2.5 border-b border-slate-200"> |
| <div className="flex items-center gap-2 pl-8"> |
| <div className="w-1 h-4 bg-indigo-500 rounded-full" /> |
| <span className="text-sm font-bold text-slate-700"> |
| {productGroup.productName} |
| </span> |
| <span className="text-xs text-slate-500"> |
| ({productGroup.molds.length} 模具) |
| </span> |
| </div> |
| </div> |
| |
| {/* Mold Data Table - Responsive: Table on desktop, Cards on mobile */} |
| <div className="bg-white"> |
| {/* Table Header - Hidden on mobile, shown on desktop */} |
| {/* CRITICAL: Must use grid-cols-12 to match data row layout exactly */} |
| <div className="hidden md:grid md:grid-cols-12 md:gap-4 md:items-center md:px-4 md:py-3 md:bg-slate-50 md:border-b md:border-slate-200"> |
| {/* Col 1-2: Mold Number Header (2 cols) */} |
| <div className="md:col-span-2 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider"> |
| 模具编号 |
| </div> |
| |
| {/* Col 3-4: Update Date Header (2 cols) */} |
| <div className="md:col-span-2 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider"> |
| 更新日期 |
| </div> |
| |
| {/* Col 5-10: Progress Details Header (6 cols) - Add pl-4 for spacing */} |
| <div className="md:col-span-6 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider pl-4"> |
| 项目推进细节 |
| </div> |
| |
| {/* Col 11-12: Current Node Header (2 cols) - Right-aligned to match data */} |
| <div className="md:col-span-2 text-right text-xs font-semibold text-slate-600 uppercase tracking-wider"> |
| 当前节点 |
| </div> |
| </div> |
| |
| {/* Data Rows - Stacked cards on mobile, table rows on desktop */} |
| <div> |
| {productGroup.molds.map((mold, index) => ( |
| <MoldDataRow key={`${mold.moldNumber}-${index}`} mold={mold} /> |
| ))} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| |
| function MoldDataRow({ mold }: { mold: MoldItem }) { |
| const isStale = isDateStale(mold.updateDate); |
| const formattedDate = formatDateShort(mold.updateDate); |
| |
| |
| const truncateText = (text: string, maxLength: number = 80) => { |
| if (!text || text === '-') return '-'; |
| return text.length > maxLength ? text.substring(0, maxLength) + '...' : text; |
| }; |
| |
| |
| const renderCurrentNode = (nodeValue: string) => { |
| |
| const status = (nodeValue || '').toString().trim(); |
| |
| switch (status) { |
| case '进行中': |
| return ( |
| <div className="flex items-center gap-1.5 text-yellow-600"> |
| <Clock className="w-4 h-4" /> |
| <span className="text-sm font-medium">进行中</span> |
| </div> |
| ); |
| |
| case '已超时': |
| return ( |
| <div className="flex items-center gap-1.5 text-red-600"> |
| <AlertCircle className="w-4 h-4" /> |
| <span className="text-sm font-bold">已超时</span> |
| </div> |
| ); |
| |
| case '已完成': |
| return ( |
| <div className="flex items-center gap-1.5 text-green-600"> |
| <CheckCircle2 className="w-4 h-4" /> |
| <span className="text-sm font-medium">已完成</span> |
| </div> |
| ); |
| |
| default: |
| |
| return ( |
| <span className="text-gray-400 text-sm"> |
| {status || '-'} |
| </span> |
| ); |
| } |
| }; |
| |
| return ( |
| <div className="relative border-b border-slate-100 hover:bg-slate-50 transition-colors"> |
| {/* Mobile: Vertical Card Layout */} |
| <div className="flex flex-col p-4 md:hidden"> |
| {/* Top Row: Mold No (Left) + Current Node (Right) */} |
| <div className="flex items-start justify-between mb-2"> |
| <div className="font-bold text-gray-800"> |
| {mold.moldNumber} |
| </div> |
| <div className="ml-4"> |
| {renderCurrentNode(mold.currentNode)} |
| </div> |
| </div> |
| |
| {/* Middle Row: Update Date */} |
| <div className="text-sm text-gray-400 mb-2"> |
| <span className="font-medium">更新于: </span> |
| <span className={isStale ? 'text-red-600 font-bold' : ''}> |
| {formattedDate} |
| </span> |
| {isStale && ( |
| <span className="ml-1.5 text-xs text-red-500">⚠</span> |
| )} |
| </div> |
| |
| {/* Bottom Row: Progress Details (Full width, wrapping allowed) */} |
| <div className="text-sm text-gray-600 break-words whitespace-normal"> |
| {mold.progressDetails} |
| </div> |
| </div> |
| |
| {/* Desktop: CSS Grid 12-Column Layout */} |
| <div className="hidden md:grid md:grid-cols-12 md:gap-4 md:items-start md:py-3 md:px-4"> |
| {/* Col 1-2: Mold Number (2 cols) */} |
| <div className="md:col-span-2"> |
| <div className="font-medium text-gray-900 text-sm"> |
| {mold.moldNumber} |
| </div> |
| </div> |
| |
| {/* Col 3-4: Update Date (2 cols) */} |
| <div className="md:col-span-2"> |
| <span className={`text-sm ${isStale ? 'text-red-600 font-bold' : 'text-gray-500'}`}> |
| {formattedDate} |
| </span> |
| {isStale && ( |
| <span className="ml-1.5 text-xs text-red-500">⚠</span> |
| )} |
| </div> |
| |
| {/* Col 5-10: Progress Details (6 cols = 50% width) - Add pl-4 to match header */} |
| <div className="md:col-span-6 pl-4"> |
| <div className="text-sm text-gray-700 whitespace-normal break-words leading-relaxed"> |
| {mold.progressDetails} |
| </div> |
| </div> |
| |
| {/* Col 11-12: Current Node (2 cols, right-aligned) */} |
| <div className="md:col-span-2 flex justify-end"> |
| {renderCurrentNode(mold.currentNode)} |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|