This commit is contained in:
2026-01-23 18:34:34 +08:00
parent e53c3c838c
commit fd15ec296b
23 changed files with 916 additions and 356 deletions

View File

@@ -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>

View File

@@ -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 &gt; 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} />
&gt; 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} />
&gt; 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">

View 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>
);
};

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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 {