20260123
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
} from 'recharts';
|
||||
import { Filter, Activity, Download, Info, CheckCircle, HelpCircle, XCircle } from 'lucide-react';
|
||||
import { Card } from './common/Card';
|
||||
import { Tooltip } from './common/Tooltip';
|
||||
import { dashboardApi, reportApi } from '../services/api';
|
||||
import type { DashboardKPI, FunnelData, AttributionRow } from '../types';
|
||||
|
||||
@@ -127,22 +128,42 @@ export const DashboardView: React.FC = () => {
|
||||
<div className="text-[10px] text-slate-400 mt-1">Total Pipeline</div>
|
||||
</Card>
|
||||
<Card className="p-4 border-l-4 border-l-purple-500">
|
||||
<div className="text-xs text-slate-500 mb-1">送樣轉換率</div>
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||
送樣轉換率
|
||||
<Tooltip content={`送樣轉換率 = (成功送樣的專案數 / DIT 總專案數) * 100%\n反映有多少比例的 DIT 案件成功進入送樣階段。`}>
|
||||
<HelpCircle size={12} className="cursor-help text-slate-400 hover:text-purple-600" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.sample_rate}%</div>
|
||||
<div className="text-[10px] text-purple-600 mt-1">Sample Rate</div>
|
||||
</Card>
|
||||
<Card className="p-4 border-l-4 border-l-emerald-500">
|
||||
<div className="text-xs text-slate-500 mb-1">訂單命中率</div>
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||
訂單命中率
|
||||
<Tooltip content={`訂單命中率 = (取得訂單的專案數 / DIT 總專案數) * 100%\n反映多少比例的 DIT 案件最終成功取得訂單。`}>
|
||||
<HelpCircle size={12} className="cursor-help text-slate-400 hover:text-emerald-600" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.hit_rate}%</div>
|
||||
<div className="text-[10px] text-emerald-600 mt-1">Hit Rate (Binary)</div>
|
||||
</Card>
|
||||
<Card className="p-4 border-l-4 border-l-amber-500">
|
||||
<div className="text-xs text-slate-500 mb-1">EAU 達成率</div>
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||
EAU 達成率
|
||||
<Tooltip content={`EAU 達成率 = (歸因訂單總量 / DIT 預估總用量 EAU) * 100%\n反映實際取得的訂單量佔全體潛在商機 (EAU) 的成交比例。`}>
|
||||
<HelpCircle size={12} className="cursor-help text-slate-400 hover:text-amber-600" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.fulfillment_rate}%</div>
|
||||
<div className="text-[10px] text-amber-600 mt-1">Fulfillment (LIFO)</div>
|
||||
</Card>
|
||||
<Card className="p-4 border-l-4 border-l-rose-500">
|
||||
<div className="text-xs text-slate-500 mb-1">無訂單樣品率</div>
|
||||
<div className="text-xs text-slate-500 mb-1 flex items-center gap-1">
|
||||
無訂單樣品率
|
||||
<Tooltip content={`無訂單樣品率 = (有送樣但無訂單的專案數 / 成功送樣專案數) * 100%\n反映已送樣但尚未轉單的 DIT 專案比例。`}>
|
||||
<HelpCircle size={12} className="cursor-help text-slate-400 hover:text-rose-600" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-rose-600">{kpi.no_order_sample_rate}%</div>
|
||||
<div className="text-[10px] text-rose-400 mt-1">No-Order Sample</div>
|
||||
</Card>
|
||||
|
||||
@@ -9,23 +9,25 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { Card } from './common/Card';
|
||||
import { labApi } from '../services/api';
|
||||
import type { LabKPI, ScatterPoint, OrphanSample } from '../types';
|
||||
import type { LabKPI, ScatterPoint, OrphanSample, NoDitSample } from '../types';
|
||||
|
||||
export const LabView: React.FC = () => {
|
||||
const [kpi, setKpi] = useState<LabKPI>({
|
||||
converted_count: 0,
|
||||
avg_velocity: 0,
|
||||
conversion_rate: 0,
|
||||
orphan_count: 0
|
||||
orphan_count: 0,
|
||||
no_dit_count: 0
|
||||
});
|
||||
const [scatterData, setScatterData] = useState<ScatterPoint[]>([]);
|
||||
const [orphans, setOrphans] = useState<OrphanSample[]>([]);
|
||||
const [noDitSamples, setNoDitSamples] = useState<NoDitSample[]>([]);
|
||||
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');
|
||||
const [viewMode, setViewMode] = useState<'orphans' | 'conversions' | 'no_dit'>('orphans');
|
||||
|
||||
useEffect(() => {
|
||||
loadLabData();
|
||||
@@ -46,16 +48,18 @@ export const LabView: React.FC = () => {
|
||||
|
||||
const params = start_date ? { start_date } : {};
|
||||
|
||||
const [kpiData, scatterRes, orphanRes, conversionRes] = await Promise.all([
|
||||
const [kpiData, scatterRes, orphanRes, noDitRes, conversionRes] = await Promise.all([
|
||||
labApi.getKPI(params),
|
||||
labApi.getScatter(params),
|
||||
labApi.getOrphans(),
|
||||
labApi.getNoDitSamples(),
|
||||
labApi.getConversions()
|
||||
]);
|
||||
|
||||
setKpi(kpiData);
|
||||
setScatterData(scatterRes);
|
||||
setOrphans(orphanRes);
|
||||
setNoDitSamples(noDitRes);
|
||||
setConversions(conversionRes);
|
||||
} catch (error) {
|
||||
console.error('Error loading lab data:', error);
|
||||
@@ -77,7 +81,7 @@ export const LabView: React.FC = () => {
|
||||
const groupInfo = React.useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
orphans.forEach(o => {
|
||||
const key = `${o.customer}|${o.pn}`;
|
||||
const key = `${o.customer?.trim()?.toUpperCase()}|${o.pn?.trim()?.toUpperCase()}`;
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
});
|
||||
return counts;
|
||||
@@ -100,7 +104,7 @@ export const LabView: React.FC = () => {
|
||||
送樣成效分析戰情室 (Sample Conversion Lab)
|
||||
</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
焦點分析:樣品投資報酬率 (ROI) 與 轉換速度 | 邏輯:ERP Code + PN 直接比對
|
||||
焦點分析:樣品投資報酬率 (ROI) | 核心邏輯:ERP Code 歸戶 + 時間因果濾網 (僅計算送樣後訂單)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -121,73 +125,92 @@ export const LabView: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<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' : ''}`}
|
||||
className={`p-4 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 className="text-xs text-slate-500 font-medium mb-1">成功收單總數</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.converted_count} 筆</div>
|
||||
<div className="text-[10px] text-blue-600 mt-1 flex items-center gap-1 font-bold">
|
||||
<Check size={10} />
|
||||
Converted
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-100 text-blue-600 rounded-xl">
|
||||
<TrendingUp size={24} />
|
||||
<div className="p-2 bg-blue-100 text-blue-600 rounded-lg">
|
||||
<TrendingUp size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 border-b-4 border-b-indigo-500 bg-gradient-to-br from-white to-indigo-50/30">
|
||||
<Card className="p-4 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 className="text-xs text-slate-500 font-medium mb-1">平均轉換速度</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.avg_velocity} 天</div>
|
||||
<div className="text-[10px] text-indigo-600 mt-1 flex items-center gap-1 font-bold">
|
||||
<Clock size={10} />
|
||||
Avg Velocity
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl">
|
||||
<Clock size={24} />
|
||||
<div className="p-2 bg-indigo-100 text-indigo-600 rounded-lg">
|
||||
<Clock size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 border-b-4 border-b-emerald-500 bg-gradient-to-br from-white to-emerald-50/30">
|
||||
<Card className="p-4 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 className="text-xs text-slate-500 font-medium mb-1">整體轉換倍率</div>
|
||||
<div className="text-2xl font-bold text-slate-800">{kpi.conversion_rate}%</div>
|
||||
<div className="text-[10px] text-emerald-600 mt-1 flex items-center gap-1 font-bold">
|
||||
<Target size={10} />
|
||||
ROI Rate
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-100 text-emerald-600 rounded-xl">
|
||||
<FlaskConical size={24} />
|
||||
<div className="p-2 bg-emerald-100 text-emerald-600 rounded-lg">
|
||||
<FlaskConical size={20} />
|
||||
</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' : ''}`}
|
||||
className={`p-4 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 > 90 Days
|
||||
<div className="text-xs text-slate-500 font-medium mb-1">無訂單樣品</div>
|
||||
<div className="text-2xl font-bold text-rose-600">{kpi.orphan_count} 筆</div>
|
||||
<div className="text-[10px] text-rose-400 mt-1 flex items-center gap-1 font-bold">
|
||||
<AlertTriangle size={10} />
|
||||
> 90 Days
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 bg-rose-100 text-rose-600 rounded-xl">
|
||||
<AlertTriangle size={24} />
|
||||
<div className="p-2 bg-rose-100 text-rose-600 rounded-lg">
|
||||
<AlertTriangle size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
onClick={() => setViewMode('no_dit')}
|
||||
className={`p-4 border-b-4 border-b-amber-500 bg-gradient-to-br from-white to-amber-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'no_dit' ? 'ring-2 ring-amber-500 ring-offset-2' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500 font-medium mb-1">未歸因大額樣品</div>
|
||||
<div className="text-2xl font-bold text-amber-600">{kpi.no_dit_count} 筆</div>
|
||||
<div className="text-[10px] text-amber-600 mt-1 flex items-center gap-1 font-bold">
|
||||
<HelpCircle size={10} />
|
||||
> 1000 pcs (No DIT)
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 bg-amber-100 text-amber-600 rounded-lg">
|
||||
<HelpCircle size={20} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -297,7 +320,7 @@ export const LabView: React.FC = () => {
|
||||
</Card>
|
||||
|
||||
{/* Insight Card */}
|
||||
<Card className="p-6 bg-slate-900 text-white flex flex-col justify-between">
|
||||
<Card className="p-6 !bg-slate-900 !border-slate-700 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" />
|
||||
@@ -305,17 +328,17 @@ export const LabView: React.FC = () => {
|
||||
</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>
|
||||
<p className="text-xs text-indigo-300 mb-1 font-bold">高效轉換客戶</p>
|
||||
<p className="text-sm font-medium text-slate-100">識別散佈圖中「左上角」點位,代表投入少量樣品即獲得大量訂單。</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>
|
||||
<p className="text-xs text-rose-300 mb-1 font-bold">風險警示</p>
|
||||
<p className="text-sm font-medium text-slate-100">右下角點位代表送樣頻繁但轉換效率低,需檢視應用場景或產品適配度。</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">
|
||||
<p className="text-[11px] text-indigo-100 leading-relaxed italic">
|
||||
"本模組直接比對 ERP 編號,確保不因專案名稱模糊而漏失任何實際營收數據。"
|
||||
</p>
|
||||
</div>
|
||||
@@ -324,13 +347,18 @@ export const LabView: React.FC = () => {
|
||||
|
||||
{/* 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'}`}>
|
||||
<div className={`px-6 py-4 border-b flex justify-between items-center ${viewMode === 'conversions' ? 'bg-blue-50 border-blue-200' : viewMode === 'no_dit' ? 'bg-amber-50 border-amber-200' : 'bg-rose-50 border-rose-200'}`}>
|
||||
<h3 className={`font-bold flex items-center gap-2 ${viewMode === 'conversions' ? 'text-blue-700' : viewMode === 'no_dit' ? 'text-amber-700' : 'text-rose-700'}`}>
|
||||
{viewMode === 'conversions' ? (
|
||||
<>
|
||||
<Check size={18} />
|
||||
Successful Conversions List
|
||||
</>
|
||||
) : viewMode === 'no_dit' ? (
|
||||
<>
|
||||
<HelpCircle size={18} />
|
||||
Unattributed High-Qty Samples
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertTriangle size={18} />
|
||||
@@ -345,7 +373,9 @@ export const LabView: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[10px] text-slate-400 font-medium">
|
||||
{viewMode === 'conversions' ? `共 ${conversions.length} 筆成功轉換` : `共 ${orphans.length} 筆待追蹤案件`}
|
||||
{viewMode === 'conversions' ? `共 ${conversions.length} 筆成功轉換`
|
||||
: viewMode === 'no_dit' ? `共 ${noDitSamples.length} 筆未歸因大單`
|
||||
: `共 ${orphans.length} 筆待追蹤案件`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,11 +390,19 @@ export const LabView: React.FC = () => {
|
||||
<>
|
||||
<th className="px-6 py-3">送樣資訊 (Date/Qty)</th>
|
||||
<th className="px-6 py-3">首張訂單 (Date/Qty)</th>
|
||||
<th className="px-6 py-3">訂單總數 (Total Order Qty)</th>
|
||||
<th className="px-6 py-3 text-center">轉換天數</th>
|
||||
</>
|
||||
) : viewMode === 'no_dit' ? (
|
||||
<>
|
||||
<th className="px-6 py-3">樣品單號</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">樣品單號</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 text-center">狀態</th>
|
||||
<th className="px-6 py-3 text-right">操作</th>
|
||||
@@ -390,6 +428,9 @@ export const LabView: React.FC = () => {
|
||||
<span className="font-bold text-emerald-600">{row.order_qty.toLocaleString()} pcs</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="font-bold text-emerald-700">{row.total_order_qty ? row.total_order_qty.toLocaleString() : '-'} pcs</span>
|
||||
</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} 天
|
||||
@@ -397,9 +438,28 @@ export const LabView: React.FC = () => {
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : viewMode === 'no_dit' ? (
|
||||
noDitSamples.map((row, i) => (
|
||||
<tr key={i} className="hover:bg-amber-50/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 font-mono text-xs text-slate-500">{row.order_no || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-500 text-xs">{row.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</span>
|
||||
<span className="font-bold text-amber-600">{row.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-amber-100 text-amber-700">
|
||||
請檢查 DIT 歸因
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
orphans.map((row, i) => {
|
||||
const groupKey = `${row.customer}|${row.pn}`;
|
||||
const groupKey = `${row.customer?.trim()?.toUpperCase()}|${row.pn?.trim()?.toUpperCase()}`;
|
||||
const isRepeated = (groupInfo[groupKey] || 0) > 1;
|
||||
const isSelected = selectedGroup === groupKey;
|
||||
|
||||
@@ -425,7 +485,15 @@ export const LabView: React.FC = () => {
|
||||
<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 font-mono text-xs text-slate-500">
|
||||
{row.order_no || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-slate-500 text-xs">{row.date?.replace(/(\d{4})(\d{2})(\d{2})/, '$1/$2/$3')}</span>
|
||||
<span className="font-bold text-slate-700">{row.sample_qty?.toLocaleString() || 0} pcs</span>
|
||||
</div>
|
||||
</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} 天
|
||||
@@ -461,6 +529,13 @@ export const LabView: React.FC = () => {
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{viewMode === 'no_dit' && noDitSamples.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-10 text-center text-slate-400">
|
||||
目前沒有 1000pcs 以上且未歸因的樣品。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{viewMode === 'conversions' && conversions.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-10 text-center text-slate-400">
|
||||
|
||||
70
frontend/src/components/common/Tooltip.tsx
Normal file
70
frontend/src/components/common/Tooltip.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, { useState, useRef, ReactNode, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
interface TooltipProps {
|
||||
content: ReactNode;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const Tooltip: React.FC<TooltipProps> = ({ content, children }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [style, setStyle] = useState<React.CSSProperties>({});
|
||||
const triggerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updatePosition = () => {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setStyle({
|
||||
left: `${rect.left + rect.width / 2}px`,
|
||||
top: `${rect.top - 8}px`,
|
||||
position: 'fixed',
|
||||
zIndex: 99999,
|
||||
transform: 'translate(-50%, -100%)',
|
||||
minWidth: '200px',
|
||||
maxWidth: '280px',
|
||||
pointerEvents: 'none'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
updatePosition();
|
||||
setIsVisible(true);
|
||||
};
|
||||
|
||||
// Optional: Re-calculate on scroll/resize if visible
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
window.addEventListener('scroll', updatePosition);
|
||||
window.addEventListener('resize', updatePosition);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updatePosition);
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}
|
||||
}, [isVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative inline-flex items-center cursor-help"
|
||||
ref={triggerRef}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
>
|
||||
{children}
|
||||
{isVisible && createPortal(
|
||||
<div
|
||||
className="px-3 py-2 bg-slate-800 text-white text-xs rounded-md shadow-xl whitespace-pre-line text-center"
|
||||
style={style}
|
||||
>
|
||||
{content}
|
||||
{/* Bottom Arrow */}
|
||||
<div
|
||||
className="absolute left-1/2 top-full transform -translate-x-1/2 border-4 border-transparent border-t-slate-800"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -3,23 +3,49 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-bottom-4 {
|
||||
from { transform: translateY(1rem); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
from {
|
||||
transform: translateY(1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-from-right-4 {
|
||||
from { transform: translateX(1rem); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
from {
|
||||
transform: translateX(1rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes zoom-in-95 {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
from {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
@@ -55,3 +81,8 @@
|
||||
.duration-300 {
|
||||
animation-duration: 0.3s;
|
||||
}
|
||||
|
||||
/* Ensure tooltips are not clipped by cards */
|
||||
.card-tooltip-container {
|
||||
overflow: visible !important;
|
||||
}
|
||||
@@ -13,11 +13,13 @@ import type {
|
||||
LabKPI,
|
||||
ScatterPoint,
|
||||
OrphanSample,
|
||||
ConversionRecord
|
||||
ConversionRecord,
|
||||
NoDitSample
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 15000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -178,6 +180,11 @@ export const labApi = {
|
||||
const response = await api.get<ConversionRecord[]>('/lab/conversions');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getNoDitSamples: async (): Promise<NoDitSample[]> => {
|
||||
const response = await api.get<NoDitSample[]>('/lab/no_dit_samples');
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
@@ -122,6 +122,7 @@ export interface LabKPI {
|
||||
avg_velocity: number;
|
||||
conversion_rate: number;
|
||||
orphan_count: number;
|
||||
no_dit_count: number;
|
||||
}
|
||||
|
||||
export interface ScatterPoint {
|
||||
@@ -137,6 +138,16 @@ export interface OrphanSample {
|
||||
days_since_sent: number;
|
||||
order_no: string;
|
||||
date: string;
|
||||
sample_qty: number;
|
||||
}
|
||||
|
||||
export interface NoDitSample {
|
||||
sample_id: string;
|
||||
customer: string;
|
||||
pn: string;
|
||||
order_no: string;
|
||||
date: string;
|
||||
qty: number;
|
||||
}
|
||||
|
||||
export interface ConversionRecord {
|
||||
|
||||
Reference in New Issue
Block a user