20260126
This commit is contained in:
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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} />
|
||||
> 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">
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user