README / client /src /components /ProjectHierarchyTable.tsx
firoruheqo4's picture
Upload 139 files
926b211 verified
Raw
History Blame Contribute Delete
10.1 kB
/**
* ProjectHierarchyTable - Leadership Overview Module
*
* Three-level hierarchical table:
* Level 1: Project Name (Dark header)
* Level 2: Product Name (Light header, indented)
* Level 3: Mold data rows (Table format)
*
* Design: Dense, scannable, desktop-optimized with mobile scroll
*/
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);
// Collapse state for each project
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>
);
}
// Level 1: Project Name Row
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>
);
}
// Level 2: Product Name + Level 3: Mold Table
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>
);
}
// Level 3: Individual Mold Row
function MoldDataRow({ mold }: { mold: MoldItem }) {
const isStale = isDateStale(mold.updateDate);
const formattedDate = formatDateShort(mold.updateDate);
// Truncate long text
const truncateText = (text: string, maxLength: number = 80) => {
if (!text || text === '-') return '-';
return text.length > maxLength ? text.substring(0, maxLength) + '...' : text;
};
// Render current node: Show all, highlight critical
const renderCurrentNode = (nodeValue: string) => {
// Data cleaning: trim to remove invisible spaces
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:
// Show all other status text (T1, PQR, PB2, etc.) in light gray
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>
);
}