Files
SalesPipeline/frontend/src/components/LabView.tsx
2026-01-16 18:16:33 +08:00

478 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import {
ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid, Tooltip as RechartsTooltip,
ResponsiveContainer, Label
} from 'recharts';
import {
FlaskConical, Calendar, Clock, Target, AlertTriangle, Copy,
Check, TrendingUp, Info, HelpCircle
} from 'lucide-react';
import { Card } from './common/Card';
import { labApi } from '../services/api';
import type { LabKPI, ScatterPoint, OrphanSample } from '../types';
export const LabView: React.FC = () => {
const [kpi, setKpi] = useState<LabKPI>({
converted_count: 0,
avg_velocity: 0,
conversion_rate: 0,
orphan_count: 0
});
const [scatterData, setScatterData] = useState<ScatterPoint[]>([]);
const [orphans, setOrphans] = useState<OrphanSample[]>([]);
const [conversions, setConversions] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [dateRange, setDateRange] = useState<'all' | '12m' | '6m' | '3m'>('all');
const [useLogScale, setUseLogScale] = useState(false);
const [copiedId, setCopiedId] = useState<number | null>(null);
const [viewMode, setViewMode] = useState<'orphans' | 'conversions'>('orphans');
useEffect(() => {
loadLabData();
}, [dateRange]);
const loadLabData = async () => {
setLoading(true);
try {
let start_date = '';
const now = new Date();
if (dateRange === '12m') {
start_date = new Date(now.setFullYear(now.getFullYear() - 1)).toISOString().split('T')[0];
} else if (dateRange === '6m') {
start_date = new Date(now.setMonth(now.getMonth() - 6)).toISOString().split('T')[0];
} else if (dateRange === '3m') {
start_date = new Date(now.setMonth(now.getMonth() - 3)).toISOString().split('T')[0];
}
const params = start_date ? { start_date } : {};
const [kpiData, scatterRes, orphanRes, conversionRes] = await Promise.all([
labApi.getKPI(params),
labApi.getScatter(params),
labApi.getOrphans(),
labApi.getConversions()
]);
setKpi(kpiData);
setScatterData(scatterRes);
setOrphans(orphanRes);
setConversions(conversionRes);
} catch (error) {
console.error('Error loading lab data:', error);
} finally {
setLoading(false);
}
};
const handleCopy = (orphan: OrphanSample, index: number) => {
const text = `Customer: ${orphan.customer}\nPart No: ${orphan.pn}\nSent Date: ${orphan.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}\nDays Ago: ${orphan.days_since_sent}`;
navigator.clipboard.writeText(text);
setCopiedId(index);
setTimeout(() => setCopiedId(null), 2000);
};
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
// Calculate grouping info
const groupInfo = React.useMemo(() => {
const counts: Record<string, number> = {};
orphans.forEach(o => {
const key = `${o.customer}|${o.pn}`;
counts[key] = (counts[key] || 0) + 1;
});
return counts;
}, [orphans]);
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<FlaskConical className="text-indigo-600" />
(Sample Conversion Lab)
</h1>
<p className="text-slate-500 mt-1">
(ROI) | ERP Code + PN
</p>
</div>
<div className="flex items-center gap-2 bg-white p-1 rounded-lg border border-slate-200 shadow-sm">
{(['all', '12m', '6m', '3m'] as const).map((r) => (
<button
key={r}
onClick={() => setDateRange(r)}
className={`px-3 py-1.5 text-xs font-bold rounded-md transition-all ${dateRange === r
? 'bg-indigo-600 text-white'
: 'text-slate-500 hover:bg-slate-50'
}`}
>
{r === 'all' ? '全部時間' : r === '12m' ? '最近 12 個月' : r === '6m' ? '最近 6 個月' : '最近 3 個月'}
</button>
))}
</div>
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<Card
onClick={() => setViewMode('conversions')}
className={`p-6 border-b-4 border-b-blue-500 bg-gradient-to-br from-white to-blue-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'conversions' ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
>
<div className="flex justify-between items-start">
<div>
<div className="text-sm text-slate-500 font-medium mb-1"></div>
<div className="text-3xl font-bold text-slate-800">{kpi.converted_count} </div>
<div className="text-xs text-blue-600 mt-2 flex items-center gap-1 font-bold">
<Check size={12} />
Converted Samples
</div>
</div>
<div className="p-3 bg-blue-100 text-blue-600 rounded-xl">
<TrendingUp size={24} />
</div>
</div>
</Card>
<Card className="p-6 border-b-4 border-b-indigo-500 bg-gradient-to-br from-white to-indigo-50/30">
<div className="flex justify-between items-start">
<div>
<div className="text-sm text-slate-500 font-medium mb-1"></div>
<div className="text-3xl font-bold text-slate-800">{kpi.avg_velocity} </div>
<div className="text-xs text-indigo-600 mt-2 flex items-center gap-1 font-bold">
<Clock size={12} />
Conversion Velocity
</div>
</div>
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl">
<Clock size={24} />
</div>
</div>
</Card>
<Card className="p-6 border-b-4 border-b-emerald-500 bg-gradient-to-br from-white to-emerald-50/30">
<div className="flex justify-between items-start">
<div>
<div className="text-sm text-slate-500 font-medium mb-1"> (ROI)</div>
<div className="text-3xl font-bold text-slate-800">{kpi.conversion_rate}%</div>
<div className="text-xs text-emerald-600 mt-2 flex items-center gap-1 font-bold">
<Target size={12} />
Sample to Order Ratio
</div>
</div>
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-xl">
<FlaskConical size={24} />
</div>
</div>
</Card>
<Card
onClick={() => setViewMode('orphans')}
className={`p-6 border-b-4 border-b-rose-500 bg-gradient-to-br from-white to-rose-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'orphans' ? 'ring-2 ring-rose-500 ring-offset-2' : ''}`}
>
<div className="flex justify-between items-start">
<div>
<div className="text-sm text-slate-500 font-medium mb-1"></div>
<div className="text-3xl font-bold text-rose-600">{kpi.orphan_count} </div>
<div className="text-xs text-rose-400 mt-2 flex items-center gap-1 font-bold">
<AlertTriangle size={12} />
Wait-time &gt; 90 Days
</div>
</div>
<div className="p-3 bg-rose-100 text-rose-600 rounded-xl">
<AlertTriangle size={24} />
</div>
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Scatter Matrix */}
<Card className="lg:col-span-2 p-6 overflow-hidden relative">
<div className="flex justify-between items-center mb-6">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<FlaskConical size={18} className="text-indigo-500" />
(Sample ROI Matrix)
</h3>
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 font-medium">Log Scale</label>
<button
onClick={() => setUseLogScale(!useLogScale)}
className={`w-10 h-5 rounded-full transition-colors relative ${useLogScale ? 'bg-indigo-600' : 'bg-slate-200'}`}
>
<div className={`absolute top-1 w-3 h-3 bg-white rounded-full transition-all ${useLogScale ? 'left-6' : 'left-1'}`} />
</button>
</div>
</div>
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart
margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
onClick={(data: any) => {
if (data && data.activePayload && data.activePayload[0]) {
const point = data.activePayload[0].payload as ScatterPoint;
if (point.order_qty > 0) {
setViewMode('conversions');
} else {
setViewMode('orphans');
}
}
}}
>
<CartesianGrid strokeDasharray="3 3" stroke="#f1f5f9" />
<XAxis
type="number"
dataKey="sample_qty"
name="Sample Qty"
scale={useLogScale ? "log" : "linear"}
domain={useLogScale ? ['auto', 'auto'] : [0, 'auto']}
>
<Label value="樣品端 (Samples Sent)" offset={-10} position="insideBottom" style={{ fontSize: '12px', fill: '#64748b', fontWeight: 600 }} />
</XAxis>
<YAxis
type="number"
dataKey="order_qty"
name="Order Qty"
scale={useLogScale ? "log" : "linear"}
domain={useLogScale ? ['auto', 'auto'] : [0, 'auto']}
>
<Label value="訂單端 (Orders Received)" angle={-90} position="insideLeft" style={{ fontSize: '12px', fill: '#64748b', fontWeight: 600 }} />
</YAxis>
<ZAxis type="number" range={[60, 400]} />
<RechartsTooltip
cursor={{ strokeDasharray: '3 3' }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload as ScatterPoint;
return (
<div className="bg-white border border-slate-200 p-3 rounded-lg shadow-xl outline-none ring-0">
<p className="font-bold text-slate-800">{data.customer}</p>
<p className="text-xs text-indigo-600 font-mono mb-2">{data.pn}</p>
<div className="space-y-1 border-t border-slate-100 pt-2">
<p className="text-xs flex justify-between gap-4">
<span className="text-slate-500">:</span>
<span className="font-bold">{data.sample_qty.toLocaleString()}</span>
</p>
<p className="text-xs flex justify-between gap-4">
<span className="text-slate-500">:</span>
<span className="font-bold text-emerald-600">{data.order_qty.toLocaleString()}</span>
</p>
<p className="text-[10px] text-slate-400 mt-2 italic">
{data.order_qty > data.sample_qty ? '✨ 高效轉換 (High ROI)' : data.order_qty > 0 ? '穩定轉換' : '尚無訂單 (No-Order)'}
</p>
</div>
</div>
);
}
return null;
}}
/>
<Scatter
name="Projects"
data={scatterData}
fill="#6366f1"
fillOpacity={0.6}
stroke="#4338ca"
strokeWidth={1}
cursor="pointer"
/>
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="absolute top-20 right-10 flex flex-col gap-2 text-[10px]">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-indigo-500 opacity-60"></div>
<span className="text-slate-500"> (Customer/PN Group)</span>
</div>
</div>
</Card>
{/* Insight Card */}
<Card className="p-6 bg-slate-900 text-white flex flex-col justify-between">
<div>
<h3 className="font-bold text-slate-100 mb-4 flex items-center gap-2">
<Info size={18} className="text-indigo-400" />
(Lab Insights)
</h3>
<div className="space-y-4">
<div className="p-3 bg-slate-800/50 rounded-lg border border-slate-700">
<p className="text-xs text-slate-400 mb-1"></p>
<p className="text-sm font-medium"></p>
</div>
<div className="p-3 bg-slate-800/50 rounded-lg border border-slate-700">
<p className="text-xs text-slate-400 mb-1"></p>
<p className="text-sm font-medium"></p>
</div>
</div>
</div>
<div className="mt-8 p-4 bg-indigo-600/20 rounded-xl border border-indigo-500/30">
<p className="text-[11px] text-indigo-300 leading-relaxed italic">
"本模組直接比對 ERP 編號,確保不因專案名稱模糊而漏失任何實際營收數據。"
</p>
</div>
</Card>
</div>
{/* Dynamic Table Section */}
<Card className="overflow-hidden">
<div className={`px-6 py-4 border-b flex justify-between items-center ${viewMode === 'conversions' ? 'bg-blue-50 border-blue-200' : 'bg-rose-50 border-rose-200'}`}>
<h3 className={`font-bold flex items-center gap-2 ${viewMode === 'conversions' ? 'text-blue-700' : 'text-rose-700'}`}>
{viewMode === 'conversions' ? (
<>
<Check size={18} />
Successful Conversions List
</>
) : (
<>
<AlertTriangle size={18} />
No-Order Sample Alert Table
</>
)}
</h3>
<div className="flex items-center gap-4">
{viewMode === 'orphans' && (
<div className="text-[10px] text-slate-400 flex items-center gap-2">
<span className="flex items-center gap-1"><span className="w-2 h-2 bg-indigo-600 rounded-full"></span> (Repeated)</span>
</div>
)}
<div className="text-[10px] text-slate-400 font-medium">
{viewMode === 'conversions' ? `${conversions.length} 筆成功轉換` : `${orphans.length} 筆待追蹤案件`}
</div>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-white text-slate-500 border-b border-slate-200">
<tr>
<th className="px-6 py-3"></th>
<th className="px-6 py-3"> (Part No)</th>
{viewMode === 'conversions' ? (
<>
<th className="px-6 py-3"> (Date/Qty)</th>
<th className="px-6 py-3"> (Date/Qty)</th>
<th className="px-6 py-3 text-center"></th>
</>
) : (
<>
<th className="px-6 py-3"></th>
<th className="px-6 py-3 text-center"></th>
<th className="px-6 py-3 text-center"></th>
<th className="px-6 py-3 text-right"></th>
</>
)}
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{viewMode === 'conversions' ? (
conversions.map((row, i) => (
<tr key={i} className="hover:bg-slate-50">
<td className="px-6 py-4 font-medium text-slate-800">{row.customer}</td>
<td className="px-6 py-4 font-mono text-xs text-slate-600">{row.pn}</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-slate-500 text-xs">{row.sample_date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</span>
<span className="font-bold text-slate-700">{row.sample_qty} pcs</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col">
<span className="text-slate-500 text-xs">{row.order_date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</span>
<span className="font-bold text-emerald-600">{row.order_qty.toLocaleString()} pcs</span>
</div>
</td>
<td className="px-6 py-4 text-center">
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-bold bg-blue-100 text-blue-700">
{row.days_to_convert}
</span>
</td>
</tr>
))
) : (
orphans.map((row, i) => {
const groupKey = `${row.customer}|${row.pn}`;
const isRepeated = (groupInfo[groupKey] || 0) > 1;
const isSelected = selectedGroup === groupKey;
return (
<tr
key={i}
onClick={() => setSelectedGroup(isSelected ? null : groupKey)}
className={`
transition-all cursor-pointer border-l-4
${isSelected ? 'bg-indigo-50 border-l-indigo-500 shadow-inner' : 'hover:bg-slate-50 border-l-transparent'}
`}
>
<td className="px-6 py-4">
<div className={`font-medium ${isRepeated ? 'text-indigo-700' : 'text-slate-800'}`}>
{row.customer}
{isRepeated && (
<span className="ml-2 inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-indigo-100 text-indigo-700 font-bold">
x{groupInfo[groupKey]}
</span>
)}
</div>
</td>
<td className="px-6 py-4 font-mono text-xs text-slate-600">
{row.pn}
</td>
<td className="px-6 py-4 text-slate-500">{row.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</td>
<td className="px-6 py-4 text-center">
<span className={`font-bold ${row.days_since_sent > 180 ? 'text-rose-600' : 'text-amber-600'}`}>
{row.days_since_sent}
</span>
</td>
<td className="px-6 py-4 text-center">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${row.days_since_sent > 180 ? 'bg-rose-100 text-rose-700' : 'bg-amber-100 text-amber-700'
}`}>
{row.days_since_sent > 180 ? '呆滯庫存 (Dead Stock)' : '需採取行動'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button
onClick={(e) => {
e.stopPropagation();
handleCopy(row, i);
}}
className="inline-flex items-center gap-1 text-xs text-indigo-600 hover:text-indigo-800 font-medium bg-indigo-50 px-2 py-1 rounded"
>
{copiedId === i ? <Check size={12} /> : <Copy size={12} />}
{copiedId === i ? '已複製' : '複製詳情'}
</button>
</td>
</tr>
)
})
)}
{viewMode === 'orphans' && orphans.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-slate-400">
</td>
</tr>
)}
{viewMode === 'conversions' && conversions.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-10 text-center text-slate-400">
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
);
};