This commit is contained in:
2026-01-16 18:16:33 +08:00
parent 9f3c96ce73
commit e53c3c838c
26 changed files with 1473 additions and 386 deletions

View File

@@ -14,13 +14,14 @@ export const DashboardView: React.FC = () => {
sample_rate: 0,
hit_rate: 0,
fulfillment_rate: 0,
orphan_sample_rate: 0,
no_order_sample_rate: 0,
total_revenue: 0,
});
const [funnelData, setFunnelData] = useState<FunnelData[]>([]);
const [attribution, setAttribution] = useState<AttributionRow[]>([]);
const [loading, setLoading] = useState(true);
const [filterType, setFilterType] = useState<'all' | 'sample' | 'order'>('all');
const [isExporting, setIsExporting] = useState(false);
useEffect(() => {
loadDashboardData();
@@ -44,11 +45,23 @@ export const DashboardView: React.FC = () => {
};
const handleExport = async (format: 'xlsx' | 'pdf') => {
if (isExporting) return;
setIsExporting(true);
console.log(`Starting export for ${format}`);
// alert(`Starting export for ${format}...`); // Debugging
try {
const blob = format === 'xlsx'
const response = format === 'xlsx'
? await reportApi.exportExcel()
: await reportApi.exportPdf();
console.log('Response received', response);
const blob = response; // response is already a blob from api.ts
if (blob.size === 0) {
alert('Export failed: Received empty file.');
return;
}
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -57,9 +70,12 @@ export const DashboardView: React.FC = () => {
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
console.log('Download triggered');
} catch (error) {
console.error('Export error:', error);
alert('匯出失敗,請稍後再試');
alert(`匯出失敗: ${error instanceof Error ? error.message : 'Unknown error'}`);
} finally {
setIsExporting(false);
}
};
@@ -88,10 +104,18 @@ export const DashboardView: React.FC = () => {
</div>
<button
onClick={() => handleExport('xlsx')}
className="flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-slate-600 hover:bg-slate-50 text-sm font-medium"
disabled={isExporting}
className={`flex items-center gap-2 px-4 py-2 border border-slate-300 rounded-lg text-sm font-medium transition-all ${isExporting
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'text-slate-600 hover:bg-slate-50'
}`}
>
<Download size={16} />
{isExporting ? (
<div className="w-4 h-4 border-2 border-slate-400 border-t-transparent rounded-full animate-spin"></div>
) : (
<Download size={16} />
)}
{isExporting ? '匯出中...' : '匯出報表'}
</button>
</div>
@@ -118,9 +142,9 @@ export const DashboardView: React.FC = () => {
<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-2xl font-bold text-rose-600">{kpi.orphan_sample_rate}%</div>
<div className="text-[10px] text-rose-400 mt-1">Orphan Sample</div>
<div className="text-xs text-slate-500 mb-1"></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>
</div>
@@ -220,7 +244,12 @@ export const DashboardView: React.FC = () => {
{filteredAttribution.map((row, i) => (
<tr key={i} className="hover:bg-slate-50 group transition-colors">
<td className="px-6 py-3">
<div className="font-mono text-xs text-slate-500 font-bold">{row.dit.op_id}</div>
<div className="font-mono text-xs text-slate-700 font-bold">{row.dit.op_id}</div>
{row.dit.op_name && (
<div className="text-[10px] text-indigo-800 truncate max-w-[150px] my-0.5" title={row.dit.op_name}>
{row.dit.op_name}
</div>
)}
<div className="text-[10px] text-slate-400">{row.dit.date}</div>
</td>
<td className="px-6 py-3">

View File

@@ -119,6 +119,35 @@ export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
<p className="text-slate-500 mt-1"> Excel/CSV </p>
</div>
<div className="flex gap-3">
<button
onClick={async () => {
if (confirm('確定要清除所有已上傳的資料與分析結果嗎?')) {
try {
await etlApi.clearData();
setFiles({
dit: { file: null, parsed: null, loading: false },
sample: { file: null, parsed: null, loading: false },
order: { file: null, parsed: null, loading: false },
});
// Reset file inputs
Object.values(fileInputRefs).forEach(ref => {
if (ref.current) ref.current.value = '';
});
setError(null);
setProcessingStep('');
// Reload page to refresh other components if needed, or just notify parent
window.location.reload();
} catch (err) {
console.error(err);
setError('清除資料失敗');
}
}
}}
className="flex items-center gap-2 px-4 py-3 rounded-lg text-red-600 bg-red-50 hover:bg-red-100 font-bold border border-red-200 transition-all"
>
<RefreshCw size={18} />
Data
</button>
<button
onClick={runEtl}
disabled={isProcessing || !allFilesReady}
@@ -259,7 +288,7 @@ export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
<th className="px-4 py-2">Customer</th>
<th className="px-4 py-2">Part No</th>
<th className="px-4 py-2">Status</th>
<th className="px-4 py-2 text-right">Qty</th>
<th className="px-4 py-2 text-right">Qty / Kpcs</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">

View File

@@ -13,16 +13,19 @@ 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();
@@ -43,15 +46,17 @@ export const LabView: React.FC = () => {
const params = start_date ? { start_date } : {};
const [kpiData, scatterRes, orphanRes] = await Promise.all([
const [kpiData, scatterRes, orphanRes, conversionRes] = await Promise.all([
labApi.getKPI(params),
labApi.getScatter(params),
labApi.getOrphans()
labApi.getOrphans(),
labApi.getConversions()
]);
setKpi(kpiData);
setScatterData(scatterRes);
setOrphans(orphanRes);
setConversions(conversionRes);
} catch (error) {
console.error('Error loading lab data:', error);
} finally {
@@ -60,12 +65,24 @@ export const LabView: React.FC = () => {
};
const handleCopy = (orphan: OrphanSample, index: number) => {
const text = `Customer: ${orphan.customer}\nPart No: ${orphan.pn}\nSent Date: ${orphan.date}\nDays Ago: ${orphan.days_since_sent}`;
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">
@@ -104,7 +121,26 @@ export const LabView: React.FC = () => {
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<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>
@@ -116,7 +152,7 @@ export const LabView: React.FC = () => {
</div>
</div>
<div className="p-3 bg-indigo-100 text-indigo-600 rounded-xl">
<TrendingUp size={24} />
<Clock size={24} />
</div>
</div>
</Card>
@@ -137,10 +173,13 @@ export const LabView: React.FC = () => {
</div>
</Card>
<Card className="p-6 border-b-4 border-b-rose-500 bg-gradient-to-br from-white to-rose-50/30">
<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-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} />
@@ -175,7 +214,19 @@ export const LabView: React.FC = () => {
<div className="h-[400px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 20, right: 20, bottom: 20, left: 20 }}>
<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"
@@ -215,7 +266,7 @@ export const LabView: React.FC = () => {
<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 ? '穩定轉換' : '尚無訂單 (Orphan?)'}
{data.order_qty > data.sample_qty ? '✨ 高效轉換 (High ROI)' : data.order_qty > 0 ? '穩定轉換' : '尚無訂單 (No-Order)'}
</p>
</div>
</div>
@@ -231,6 +282,7 @@ export const LabView: React.FC = () => {
fillOpacity={0.6}
stroke="#4338ca"
strokeWidth={1}
cursor="pointer"
/>
</ScatterChart>
</ResponsiveContainer>
@@ -270,61 +322,149 @@ export const LabView: React.FC = () => {
</Card>
</div>
{/* Orphan Samples Table */}
{/* Dynamic Table Section */}
<Card className="overflow-hidden">
<div className="px-6 py-4 bg-slate-50 border-b border-slate-200 flex justify-between items-center">
<h3 className="font-bold text-slate-700 flex items-center gap-2">
<AlertTriangle size={18} className="text-rose-500" />
Orphan Alert Table - &gt; 90 Days
<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="text-[10px] text-slate-400 font-medium">
{orphans.length}
<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>
<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>
{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">
{orphans.map((row, i) => (
<tr key={i} className="hover:bg-slate-50 transition-colors group">
<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 text-slate-500">{row.date}</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={() => 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"
{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'}
`}
>
{copiedId === i ? <Check size={12} /> : <Copy size={12} />}
{copiedId === i ? '已複製' : '複製詳情'}
</button>
</td>
</tr>
))}
{orphans.length === 0 && (
<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>
)}

View File

@@ -83,9 +83,16 @@ export const ReviewView: React.FC<ReviewViewProps> = ({ onReviewComplete }) => {
<div className="flex flex-col md:flex-row">
{/* Left: DIT */}
<div className="flex-1 p-5 border-b md:border-b-0 md:border-r border-slate-100 bg-slate-50/50">
<div className="flex items-center gap-2 mb-2">
<Badge type="info">DIT ()</Badge>
<span className="text-xs text-slate-400">OP編號: {item.dit?.op_id}</span>
<div className="flex flex-col gap-1 mb-3">
<div className="flex items-center gap-2">
<Badge type="info">DIT ()</Badge>
<span className="text-xs text-slate-500 font-mono">OP NO: {item.dit?.op_id}</span>
</div>
{item.dit?.op_name && (
<div className="text-sm font-medium text-indigo-900 line-clamp-2" title={item.dit.op_name}>
{item.dit.op_name}
</div>
)}
</div>
<div className="space-y-1">
<div className="text-xs text-slate-400 uppercase">Customer Name</div>

View File

@@ -12,7 +12,8 @@ import type {
OrderRecord,
LabKPI,
ScatterPoint,
OrphanSample
OrphanSample,
ConversionRecord
} from '../types';
const api = axios.create({
@@ -95,6 +96,10 @@ export const etlApi = {
const response = await api.get(`/etl/data/${type}`);
return response.data;
},
clearData: async (): Promise<void> => {
await api.delete('/etl/data');
},
};
// Match API
@@ -168,6 +173,11 @@ export const labApi = {
const response = await api.get<OrphanSample[]>('/lab/orphans');
return response.data;
},
getConversions: async (): Promise<ConversionRecord[]> => {
const response = await api.get<ConversionRecord[]>('/lab/conversions');
return response.data;
},
};
export default api;

View File

@@ -23,6 +23,7 @@ export interface LoginResponse {
export interface DitRecord {
id: number;
op_id: string;
op_name?: string;
erp_account?: string;
customer: string;
pn: string;
@@ -84,7 +85,7 @@ export interface DashboardKPI {
sample_rate: number;
hit_rate: number;
fulfillment_rate: number;
orphan_sample_rate: number;
no_order_sample_rate: number;
total_revenue: number;
}
@@ -117,6 +118,7 @@ export interface ParsedFile {
// Lab 分析相關類型
export interface LabKPI {
converted_count: number;
avg_velocity: number;
conversion_rate: number;
orphan_count: number;
@@ -137,6 +139,16 @@ export interface OrphanSample {
date: string;
}
export interface ConversionRecord {
customer: string;
pn: string;
sample_date: string;
sample_qty: number;
order_date: string;
order_qty: number;
days_to_convert: number;
}
// API 響應包裝
export interface ApiResponse<T> {
success: boolean;