This commit is contained in:
2026-01-26 18:51:33 +08:00
parent fd15ec296b
commit 0918f7ae5a
7 changed files with 415 additions and 92 deletions

View File

@@ -40,6 +40,7 @@ export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
...prev,
[type]: { ...prev[type], file, loading: true }
}));
setError(null);
try {
const parsed = await etlApi.upload(file, type);
@@ -47,12 +48,17 @@ export const ImportView: React.FC<ImportViewProps> = ({ onEtlComplete }) => {
...prev,
[type]: { file, parsed, loading: false }
}));
} catch (error) {
} catch (error: any) {
console.error(`Error uploading ${type} file:`, error);
setFiles(prev => ({
...prev,
[type]: { file: null, parsed: null, loading: false }
[type]: { ...prev[type], parsed: null, loading: false }
}));
const msg = error.code === 'ECONNABORTED'
? '上傳逾時,檔案可能過大,請稍後再試'
: (error.response?.data?.detail || error.message || '上傳失敗');
setError(msg);
}
};

View File

@@ -9,7 +9,7 @@ import {
} from 'lucide-react';
import { Card } from './common/Card';
import { labApi } from '../services/api';
import type { LabKPI, ScatterPoint, OrphanSample, NoDitSample } from '../types';
import type { LabKPI, ScatterPoint, OrphanSample, NoDitSample, HighQtyNoOrderSample } from '../types';
export const LabView: React.FC = () => {
const [kpi, setKpi] = useState<LabKPI>({
@@ -17,17 +17,19 @@ export const LabView: React.FC = () => {
avg_velocity: 0,
conversion_rate: 0,
orphan_count: 0,
no_dit_count: 0
no_dit_count: 0,
high_qty_no_order_count: 0
});
const [scatterData, setScatterData] = useState<ScatterPoint[]>([]);
const [orphans, setOrphans] = useState<OrphanSample[]>([]);
const [noDitSamples, setNoDitSamples] = useState<NoDitSample[]>([]);
const [highQtyNoOrderSamples, setHighQtyNoOrderSamples] = useState<HighQtyNoOrderSample[]>([]);
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' | 'no_dit'>('orphans');
const [viewMode, setViewMode] = useState<'orphans' | 'conversions' | 'no_dit' | 'high_qty_no_order'>('orphans');
useEffect(() => {
loadLabData();
@@ -48,18 +50,20 @@ export const LabView: React.FC = () => {
const params = start_date ? { start_date } : {};
const [kpiData, scatterRes, orphanRes, noDitRes, conversionRes] = await Promise.all([
const [kpiData, scatterRes, orphanRes, noDitRes, highQtyNoOrderRes, conversionRes] = await Promise.all([
labApi.getKPI(params),
labApi.getScatter(params),
labApi.getOrphans(),
labApi.getNoDitSamples(),
labApi.getConversions()
labApi.getOrphans(params),
labApi.getNoDitSamples(params),
labApi.getHighQtyNoOrderSamples(params),
labApi.getConversions(params)
]);
setKpi(kpiData);
setScatterData(scatterRes);
setOrphans(orphanRes);
setNoDitSamples(noDitRes);
setHighQtyNoOrderSamples(highQtyNoOrderRes);
setConversions(conversionRes);
} catch (error) {
console.error('Error loading lab data:', error);
@@ -125,7 +129,7 @@ export const LabView: React.FC = () => {
</div>
{/* KPI Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-6 gap-4">
<Card
onClick={() => setViewMode('conversions')}
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' : ''}`}
@@ -214,6 +218,25 @@ export const LabView: React.FC = () => {
</div>
</div>
</Card>
<Card
onClick={() => setViewMode('high_qty_no_order')}
className={`p-4 border-b-4 border-b-violet-500 bg-gradient-to-br from-white to-violet-50/30 cursor-pointer transition-all hover:shadow-md ${viewMode === 'high_qty_no_order' ? 'ring-2 ring-violet-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-violet-600">{kpi.high_qty_no_order_count} </div>
<div className="text-[10px] text-violet-600 mt-1 flex items-center gap-1 font-bold">
<AlertTriangle size={10} />
&gt; 1000 pcs (No Order)
</div>
</div>
<div className="p-2 bg-violet-100 text-violet-600 rounded-lg">
<AlertTriangle size={20} />
</div>
</div>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -347,8 +370,16 @@ 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' : 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'}`}>
<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' :
viewMode === 'high_qty_no_order' ? 'bg-violet-50 border-violet-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' :
viewMode === 'high_qty_no_order' ? 'text-violet-700' :
'text-rose-700'
}`}>
{viewMode === 'conversions' ? (
<>
<Check size={18} />
@@ -359,6 +390,11 @@ export const LabView: React.FC = () => {
<HelpCircle size={18} />
Unattributed High-Qty Samples
</>
) : viewMode === 'high_qty_no_order' ? (
<>
<AlertTriangle size={18} />
High Quantity No-Order Samples
</>
) : (
<>
<AlertTriangle size={18} />
@@ -375,7 +411,8 @@ export const LabView: React.FC = () => {
<div className="text-[10px] text-slate-400 font-medium">
{viewMode === 'conversions' ? `${conversions.length} 筆成功轉換`
: viewMode === 'no_dit' ? `${noDitSamples.length} 筆未歸因大單`
: `${orphans.length}待追蹤案件`}
: viewMode === 'high_qty_no_order' ? `${highQtyNoOrderSamples.length}大額無單`
: `${orphans.length} 筆待追蹤案件`}
</div>
</div>
</div>
@@ -399,6 +436,13 @@ export const LabView: React.FC = () => {
<th className="px-6 py-3"> (Date/Qty)</th>
<th className="px-6 py-3 text-center"></th>
</>
) : viewMode === 'high_qty_no_order' ? (
<>
<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"></th>
@@ -457,6 +501,28 @@ export const LabView: React.FC = () => {
</td>
</tr>
))
) : viewMode === 'high_qty_no_order' ? (
highQtyNoOrderSamples.map((row, i) => (
<tr key={i} className="hover:bg-violet-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-violet-600">{row.qty?.toLocaleString()} pcs</span>
</div>
</td>
<td className="px-6 py-4 text-center">
<span className="font-bold text-slate-600">{row.days_since_sent} </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-violet-100 text-violet-700">
</span>
</td>
</tr>
))
) : (
orphans.map((row, i) => {
const groupKey = `${row.customer?.trim()?.toUpperCase()}|${row.pn?.trim()?.toUpperCase()}`;
@@ -529,6 +595,13 @@ export const LabView: React.FC = () => {
</td>
</tr>
)}
{viewMode === 'high_qty_no_order' && highQtyNoOrderSamples.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-10 text-center text-slate-400">
1000pcs
</td>
</tr>
)}
{viewMode === 'no_dit' && noDitSamples.length === 0 && (
<tr>
<td colSpan={5} className="px-6 py-10 text-center text-slate-400">

View File

@@ -14,12 +14,13 @@ import type {
ScatterPoint,
OrphanSample,
ConversionRecord,
NoDitSample
NoDitSample,
HighQtyNoOrderSample
} from '../types';
const api = axios.create({
baseURL: '/api',
timeout: 15000,
timeout: 900000,
headers: {
'Content-Type': 'application/json',
},
@@ -171,18 +172,23 @@ export const labApi = {
return response.data;
},
getOrphans: async (): Promise<OrphanSample[]> => {
const response = await api.get<OrphanSample[]>('/lab/orphans');
getOrphans: async (params?: { start_date?: string; end_date?: string }): Promise<OrphanSample[]> => {
const response = await api.get<OrphanSample[]>('/lab/orphans', { params });
return response.data;
},
getConversions: async (): Promise<ConversionRecord[]> => {
const response = await api.get<ConversionRecord[]>('/lab/conversions');
getConversions: async (params?: { start_date?: string; end_date?: string }): Promise<ConversionRecord[]> => {
const response = await api.get<ConversionRecord[]>('/lab/conversions', { params });
return response.data;
},
getNoDitSamples: async (): Promise<NoDitSample[]> => {
const response = await api.get<NoDitSample[]>('/lab/no_dit_samples');
getNoDitSamples: async (params?: { start_date?: string; end_date?: string }): Promise<NoDitSample[]> => {
const response = await api.get<NoDitSample[]>('/lab/no_dit_samples', { params });
return response.data;
},
getHighQtyNoOrderSamples: async (params?: { start_date?: string; end_date?: string }): Promise<HighQtyNoOrderSample[]> => {
const response = await api.get<HighQtyNoOrderSample[]>('/lab/high_qty_no_order_samples', { params });
return response.data;
},
};

View File

@@ -123,6 +123,7 @@ export interface LabKPI {
conversion_rate: number;
orphan_count: number;
no_dit_count: number;
high_qty_no_order_count: number;
}
export interface ScatterPoint {
@@ -150,6 +151,16 @@ export interface NoDitSample {
qty: number;
}
export interface HighQtyNoOrderSample {
sample_id: string;
customer: string;
pn: string;
order_no: string;
date: string;
qty: number;
days_since_sent: number;
}
export interface ConversionRecord {
customer: string;
pn: string;