Integrate stock availability data and StockBadge component across all views

This commit is contained in:
Christian Vidal Wolf
2026-01-27 16:51:38 +01:00
parent 219d050bf2
commit 0d1d2c77d4
9 changed files with 634 additions and 470 deletions
+99 -78
View File
@@ -6,7 +6,7 @@ import FilterBar from './components/FilterBar';
import AIChat from './components/AIChat'; import AIChat from './components/AIChat';
import CrazeLogo from './components/CrazeLogo'; import CrazeLogo from './components/CrazeLogo';
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData } from './types'; import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData } from './types';
import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData, processForecastExcel, calculateForecastViewData } from './services/dataProcessor'; import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData, processForecastExcel, calculateForecastViewData, processStockExcel } from './services/dataProcessor';
import { queryGemini } from './services/geminiService'; import { queryGemini } from './services/geminiService';
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage'; import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
@@ -48,6 +48,7 @@ const App: React.FC = () => {
const [isChatOpen, setIsChatOpen] = useState(false); const [isChatOpen, setIsChatOpen] = useState(false);
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL); const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
const [lastUpdated, setLastUpdated] = useState<string | null>(null); const [lastUpdated, setLastUpdated] = useState<string | null>(null);
const [stockMap, setStockMap] = useState<Map<string, number>>(new Map());
// Modal State // Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false); const [isDataModalOpen, setIsDataModalOpen] = useState(false);
@@ -124,26 +125,30 @@ const App: React.FC = () => {
} }
}, []); }, []);
const handleTrafficFetch = useCallback(async () => {
try {
console.log('[App] Fetching traffic from /api/fetch-traffic...');
const response = await fetch('/api/fetch-traffic');
if (!response.ok) {
throw new Error(`Failed to fetch Traffic: ${response.status} ${response.statusText}`);
}
const buffer = await response.arrayBuffer();
const data = await processTrafficExcel(buffer);
setTrafficData(data); setTrafficData(data);
console.log('[App] Successfully loaded', data.length, 'traffic records'); console.log('[App] Successfully loaded', data.length, 'traffic records');
} catch (error) { } catch (error) {
console.error("Failed to fetch/parse Traffic", error); console.error("Failed to fetch/parse Traffic", error);
} }
}, []); }, []);
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => { const handleStockFetch = useCallback(async () => {
try {
console.log('[App] Fetching stock from /api/fetch-stock...');
const response = await fetch('/api/fetch-stock');
if (!response.ok) throw new Error(`Failed to fetch stock: ${response.status}`);
const buffer = await response.arrayBuffer();
const data = await processStockExcel(buffer);
setStockMap(data);
console.log('[App] Successfully loaded stock for', data.size, 'normalized SKUs');
} catch (error) {
console.error("Failed to fetch/parse Stock", error);
}
}, []);
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => {
try { try {
const isUK = activeFilters.customer.includes('Amazon UK'); const isUK = activeFilters.customer.includes('Amazon UK');
const filename = isUK ? '/fc UK 26.xlsx' : '/fc 26.xlsx'; const filename = isUK ? '/fc UK 26.xlsx' : '/fc 26.xlsx';
@@ -173,10 +178,10 @@ const App: React.FC = () => {
} catch (error) { } catch (error) {
console.warn("Forecast fetch failed:", error); console.warn("Forecast fetch failed:", error);
} }
}, []); }, []);
// Update forecast when filters change // Update forecast when filters change
useEffect(() => { useEffect(() => {
if (rawData.length > 0) { if (rawData.length > 0) {
// Filter rawData to respect customer selections but keep all years (2025 for seasonality, 2026 for actuals) // Filter rawData to respect customer selections but keep all years (2025 for seasonality, 2026 for actuals)
const forecastRelevantData = filterData(rawData, { const forecastRelevantData = filterData(rawData, {
@@ -185,9 +190,9 @@ const App: React.FC = () => {
}); });
handleForecastFetch(forecastRelevantData, rawData, filters); handleForecastFetch(forecastRelevantData, rawData, filters);
} }
}, [filters, rawData, handleForecastFetch]); }, [filters, rawData, handleForecastFetch]);
const initializeData = (data: SalesRecord[]) => { const initializeData = (data: SalesRecord[]) => {
setRawData(data); setRawData(data);
setFilters({ setFilters({
customer: [], customer: [],
@@ -199,19 +204,19 @@ const App: React.FC = () => {
title: [], title: [],
week: [], week: [],
}); });
}; };
const handleSkuDrillDown = useCallback((sku: string) => { const handleSkuDrillDown = useCallback((sku: string) => {
if (!sku) return; if (!sku) return;
setFilters(prev => ({ setFilters(prev => ({
...prev, ...prev,
sku: [sku] sku: [sku]
})); }));
setView('ads'); setView('ads');
}, []); }, []);
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL // 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
useEffect(() => { useEffect(() => {
const initApp = async () => { const initApp = async () => {
setLoading(true); setLoading(true);
@@ -265,14 +270,18 @@ const App: React.FC = () => {
console.log("Fetching Traffic data..."); console.log("Fetching Traffic data...");
handleTrafficFetch(); handleTrafficFetch();
// 1d. Fetch Forecast data // 1d. Always fetch Stock data
console.log("Fetching Stock data...");
handleStockFetch();
// 1e. Fetch Forecast data
handleForecastFetch(cachedData || [], cachedData || [], filters); handleForecastFetch(cachedData || [], cachedData || [], filters);
}; };
initApp(); initApp();
}, [handleDataFetch]); }, [handleDataFetch]);
// Handle uploaded Sales file (Manual) // Handle uploaded Sales file (Manual)
const handleSalesUpload = async (file: File) => { const handleSalesUpload = async (file: File) => {
setSyncing(true); setSyncing(true);
try { try {
const data = await processCSV(file); const data = await processCSV(file);
@@ -288,10 +297,10 @@ const App: React.FC = () => {
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
}; };
// Handle uploaded Ads file (Manual) - Supports both CSV and Excel // Handle uploaded Ads file (Manual) - Supports both CSV and Excel
const handleAdsUpload = async (file: File) => { const handleAdsUpload = async (file: File) => {
setSyncing(true); setSyncing(true);
try { try {
const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls'); const isExcel = file.name.endsWith('.xlsx') || file.name.endsWith('.xls');
@@ -306,10 +315,10 @@ const App: React.FC = () => {
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
}; };
// Handle uploaded Traffic file (Manual) // Handle uploaded Traffic file (Manual)
const handleTrafficUpload = async (file: File) => { const handleTrafficUpload = async (file: File) => {
setSyncing(true); setSyncing(true);
try { try {
const data = await processTrafficExcel(file); const data = await processTrafficExcel(file);
@@ -322,10 +331,10 @@ const App: React.FC = () => {
} finally { } finally {
setSyncing(false); setSyncing(false);
} }
}; };
// 2. Schedule Auto-Refresh (Background) // 2. Schedule Auto-Refresh (Background)
useEffect(() => { useEffect(() => {
const checkAndRefresh = () => { const checkAndRefresh = () => {
const now = new Date(); const now = new Date();
const today = now.toISOString().split('T')[0]; // YYYY-MM-DD const today = now.toISOString().split('T')[0]; // YYYY-MM-DD
@@ -352,11 +361,11 @@ const App: React.FC = () => {
const interval = setInterval(checkAndRefresh, 15 * 60 * 1000); const interval = setInterval(checkAndRefresh, 15 * 60 * 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [handleDataFetch]); }, [handleDataFetch]);
// Derive Data // Derive Data
const globalAsinMetadata = useMemo(() => { const globalAsinMetadata = useMemo(() => {
const metaMap = new Map<string, { sku: string; title: string; line: string }>(); const metaMap = new Map<string, { sku: string; title: string; line: string }>();
rawData.forEach(r => { rawData.forEach(r => {
const asin = r.asin.trim().toUpperCase(); const asin = r.asin.trim().toUpperCase();
@@ -367,14 +376,14 @@ const App: React.FC = () => {
} }
}); });
return metaMap; return metaMap;
}, [rawData]); }, [rawData]);
const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]); const filteredData = useMemo(() => filterData(rawData, filters), [rawData, filters]);
const filteredAdsData = useMemo(() => filterAdsData(adsData, filters, globalAsinMetadata), [adsData, filters, globalAsinMetadata]); const filteredAdsData = useMemo(() => filterAdsData(adsData, filters, globalAsinMetadata), [adsData, filters, globalAsinMetadata]);
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]); const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
// Calculate country-aware Top 50 Best Sellers for 2025 // Calculate country-aware Top 50 Best Sellers for 2025
const top50Ranking2025 = useMemo(() => { const top50Ranking2025 = useMemo(() => {
// Filter only 2025 data // Filter only 2025 data
const data2025 = rawData.filter(r => r.year === 2025); const data2025 = rawData.filter(r => r.year === 2025);
@@ -403,15 +412,15 @@ const App: React.FC = () => {
eu: calculateTop50(euData), eu: calculateTop50(euData),
uk: calculateTop50(ukData) uk: calculateTop50(ukData)
}; };
}, [rawData]); }, [rawData]);
// Combine Sales & Ads Data dynamically based on current filters // Combine Sales & Ads Data dynamically based on current filters
const combinedAdsData = useMemo(() => { const combinedAdsData = useMemo(() => {
return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData); return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData);
}, [filteredData, filteredAdsData, globalAsinMetadata, trafficData]); }, [filteredData, filteredAdsData, globalAsinMetadata, trafficData]);
// Derive Context Data (Product Line Context when drilling down) // Derive Context Data (Product Line Context when drilling down)
const contextAggregatedData = useMemo(() => { const contextAggregatedData = useMemo(() => {
// Check if we are filtering by specific items (SKU, ASIN, Title) // Check if we are filtering by specific items (SKU, ASIN, Title)
const hasItemFilters = filters.sku.length > 0 || filters.asin.length > 0 || filters.title.length > 0; const hasItemFilters = filters.sku.length > 0 || filters.asin.length > 0 || filters.title.length > 0;
@@ -435,11 +444,11 @@ const App: React.FC = () => {
const broadData = filterData(rawData, contextFilters); const broadData = filterData(rawData, contextFilters);
return aggregateData(broadData); return aggregateData(broadData);
}, [rawData, filters, filteredData]); }, [rawData, filters, filteredData]);
// Derive Options for Filter Dropdowns // Derive Options for Filter Dropdowns
const filterOptions = useMemo(() => { const filterOptions = useMemo(() => {
return { return {
customer: getUniqueValues(rawData, 'customer'), customer: getUniqueValues(rawData, 'customer'),
year: getUniqueValues(rawData, 'year'), year: getUniqueValues(rawData, 'year'),
@@ -450,17 +459,17 @@ const App: React.FC = () => {
title: getUniqueValues(rawData, 'title'), title: getUniqueValues(rawData, 'title'),
week: Array.from(new Set(rawData.map(r => r.week).filter(w => w !== undefined))).sort((a, b) => (a as number) - (b as number)).map(w => `W${w}`), week: Array.from(new Set(rawData.map(r => r.week).filter(w => w !== undefined))).sort((a, b) => (a as number) - (b as number)).map(w => `W${w}`),
}; };
}, [rawData]); }, [rawData]);
const handleFilterChange = (key: keyof FilterState, value: string[]) => { const handleFilterChange = (key: keyof FilterState, value: string[]) => {
setFilters(prev => ({ ...prev, [key]: value })); setFilters(prev => ({ ...prev, [key]: value }));
}; };
const handleAskGemini = async (text: string) => { const handleAskGemini = async (text: string) => {
return await queryGemini(text, aggregatedData, filteredData.length); return await queryGemini(text, aggregatedData, filteredData.length);
}; };
const disconnectUrl = async () => { const disconnectUrl = async () => {
// Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement // Allow disconnecting to clear data, but the app will likely re-connect on next reload due to "Permanent" requirement
localStorage.removeItem('craze_csv_url'); localStorage.removeItem('craze_csv_url');
await clearSalesData(); await clearSalesData();
@@ -468,9 +477,9 @@ const App: React.FC = () => {
setActiveUrl(null); setActiveUrl(null);
setRawData([]); setRawData([]);
setAdsData([]); setAdsData([]);
}; };
return ( return (
<div className="min-h-screen flex flex-col bg-background text-slate-200"> <div className="min-h-screen flex flex-col bg-background text-slate-200">
{/* Header */} {/* Header */}
@@ -575,20 +584,32 @@ const App: React.FC = () => {
<> <>
<FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} /> <FilterBar filters={filters} onFilterChange={handleFilterChange} options={filterOptions} />
<div className="mt-6"> <div className="mt-6">
{view === 'dashboard' && ( {view === 'dashboard' && <Dashboard data={aggregatedData} filterState={filters} adsData={filteredAdsData} stockMap={stockMap} />}
<Dashboard {view === 'table' && (
data={aggregatedData}
contextData={contextAggregatedData}
adsData={filteredAdsData}
/>
)}
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
{view === 'table' && <DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} />} <DataGrid data={combinedAdsData} hasCustomerFilter={filters.customer.length > 0} adsData={filteredAdsData} stockMap={stockMap} />
{view === 'weekly' && <WeeklyGrid data={combinedAdsData} top50Ranking={top50Ranking2025} onDrillDown={handleSkuDrillDown} />}
{view === 'movers' && <TopMovers data={filteredData} />}
{view === 'ads' && <AdsPerformance data={combinedAdsData} filters={filters} top50Ranking={top50Ranking2025} />}
{view === 'forecast' && <ForecastView data={forecastData} filters={filters} top50Ranking={top50Ranking2025} />}
</Suspense> </Suspense>
)}
{view === 'weekly' && (
<Suspense fallback={<LoadingSpinner />}>
<WeeklyGrid data={filteredAdsData.length > 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData} onDrillDown={handleSkuDrillDown} stockMap={stockMap} />
</Suspense>
)}
{view === 'movers' && (
<Suspense fallback={<LoadingSpinner />}>
<TopMovers data={filteredData} stockMap={stockMap} />
</Suspense>
)}
{view === 'ads' && (
<Suspense fallback={<LoadingSpinner />}>
<AdsPerformance data={filteredAdsData} filters={filters} onSkuDrillDown={handleSkuDrillDown} stockMap={stockMap} />
</Suspense>
)}
{view === 'forecast' && (
<Suspense fallback={<LoadingSpinner />}>
<ForecastView data={forecastData} stockMap={stockMap} />
</Suspense>
)}
</div> </div>
</> </>
)} )}
@@ -625,7 +646,7 @@ const App: React.FC = () => {
)} )}
</div> </div>
); );
}; };
export default App; export default App;
Binary file not shown.
+49
View File
@@ -0,0 +1,49 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import fs from 'fs';
import path from 'path';
export default async function handler(req: VercelRequest, res: VercelResponse) {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
try {
console.log('[fetch-stock] Reading Stock file from local storage...');
// Try multiple possible paths to be robust
const pathsToTry = [
path.join(process.cwd(), 'Item Availability.xlsx'),
path.join(process.cwd(), 'public', 'Item Availability.xlsx'),
path.join('/Users/christianvidalwolf/github/CrazeAnalytix', 'Item Availability.xlsx')
];
let buffer = null;
let foundPath = '';
for (const p of pathsToTry) {
console.log(`[fetch-stock] Checking path: ${p}`);
if (fs.existsSync(p)) {
buffer = fs.readFileSync(p);
foundPath = p;
break;
}
}
if (!buffer) {
throw new Error(`Stock file 'Item Availability.xlsx' not found in any of: ${pathsToTry.join(', ')}`);
}
console.log(`[fetch-stock] Successfully read Stock Excel from ${foundPath}, size: ${buffer.byteLength}`);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.status(200).send(buffer);
} catch (error: any) {
console.error('[fetch-stock] Error:', error);
res.status(500).json({ error: error.message });
}
}
+6 -1
View File
@@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { CombinedKPIs, FilterState } from '../types'; import { CombinedKPIs, FilterState } from '../types';
import { DownloadIcon } from './Icons'; import { DownloadIcon } from './Icons';
import { StockBadge } from './StockBadge';
interface AdsPerformanceProps { interface AdsPerformanceProps {
data: CombinedKPIs[]; data: CombinedKPIs[];
@@ -10,6 +11,7 @@ interface AdsPerformanceProps {
eu: Map<string, number>; eu: Map<string, number>;
uk: Map<string, number>; uk: Map<string, number>;
}; };
stockMap?: Map<string, number>;
} }
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
@@ -34,7 +36,7 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl
); );
}; };
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking }) => { const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap }) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
@@ -404,6 +406,9 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} /> <Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
))} ))}
<span className="text-white font-bold text-sm tracking-tight">{row.asin}</span> <span className="text-white font-bold text-sm tracking-tight">{row.asin}</span>
{stockMap && (
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
)}
</div> </div>
<span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {row.sku}</span> <span className="text-[10px] uppercase font-black text-indigo-400 tracking-tighter">SKU: {row.sku}</span>
<span className="text-[10px] text-slate-400 truncate max-w-[220px] leading-tight" title={row.title}>{row.title}</span> <span className="text-[10px] text-slate-400 truncate max-w-[220px] leading-tight" title={row.title}>{row.title}</span>
+10 -1
View File
@@ -5,11 +5,13 @@ import {
import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types'; import { SalesRecord, PivotRow, AdsRecord, CombinedKPIs } from '../types';
import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor'; import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForComparisonTimeSeries, applyPanEUGrouping } from '../services/dataProcessor';
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons'; import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
import { StockBadge } from './StockBadge';
interface DataGridProps { interface DataGridProps {
data: SalesRecord[] | CombinedKPIs[]; data: SalesRecord[] | CombinedKPIs[];
hasCustomerFilter: boolean; hasCustomerFilter: boolean;
adsData?: AdsRecord[]; adsData?: AdsRecord[];
stockMap?: Map<string, number>;
} }
type SortConfig = { type SortConfig = {
@@ -226,7 +228,7 @@ const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode;
}; };
const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData = [] }) => { const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData, stockMap }) => {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' }); const [sortConfig, setSortConfig] = useState<SortConfig>({ key: null, direction: 'desc' });
const [showChart, setShowChart] = useState(true); const [showChart, setShowChart] = useState(true);
@@ -1081,6 +1083,13 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData =
<td key={dim} className="px-4 py-3 font-medium text-slate-200 break-words max-w-xs"> <td key={dim} className="px-4 py-3 font-medium text-slate-200 break-words max-w-xs">
{dim === 'title' {dim === 'title'
? <div className="line-clamp-2" title={row.title}>{row.title || '-'}</div> ? <div className="line-clamp-2" title={row.title}>{row.title || '-'}</div>
: (dim === 'sku' || dim === 'asin')
? <div className="flex items-center gap-2">
<span>{(row[dim as keyof PivotRow] as string) || '-'}</span>
{stockMap && dim === 'sku' && (
<StockBadge stock={stockMap.get((row[dim] as string)?.replace(/(DE|EN)$/i, ''))} />
)}
</div>
: (row[dim as keyof PivotRow] as string) || '-' : (row[dim as keyof PivotRow] as string) || '-'
} }
</td> </td>
+5 -1
View File
@@ -2,6 +2,8 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { ProductForecastData, FilterState } from '../types'; import { ProductForecastData, FilterState } from '../types';
import { DownloadIcon } from './Icons'; import { DownloadIcon } from './Icons';
import { StockBadge } from './StockBadge';
import { StockBadge } from './StockBadge';
import { import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
LineChart, Line, Legend, ComposedChart, Area LineChart, Line, Legend, ComposedChart, Area
@@ -13,7 +15,9 @@ interface ForecastViewProps {
top50Ranking?: { top50Ranking?: {
eu: Map<string, number>; eu: Map<string, number>;
uk: Map<string, number>; uk: Map<string, number>;
stockMap?: Map<string, number>;
}; };
stockMap?: Map<string, number>;
} }
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
@@ -29,7 +33,7 @@ const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'ind
); );
}; };
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking }) => { const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, stockMap }) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
+34
View File
@@ -0,0 +1,34 @@
import React from 'react';
interface StockBadgeProps {
stock: number | undefined;
}
export const StockBadge: React.FC<StockBadgeProps> = ({ stock }) => {
if (stock === undefined) return null;
const isLow = stock < 50;
const isOut = stock <= 0;
let colorClass = "bg-emerald-500/20 text-emerald-400 border-emerald-500/30";
let icon = "📦";
if (isOut) {
colorClass = "bg-rose-500/20 text-rose-400 border-rose-500/30";
icon = "❌";
} else if (isLow) {
colorClass = "bg-amber-500/20 text-amber-400 border-amber-500/30";
icon = "⚠️";
}
return (
<div
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-black border uppercase tracking-tighter shadow-sm ${colorClass}`}
title={`Total Stock (GMBH): ${stock}`}
>
<span className="text-xs">{icon}</span>
<span>{stock.toLocaleString('de-DE')}</span>
</div>
);
};
+6 -1
View File
@@ -2,6 +2,7 @@ import React, { useMemo, useState, useEffect, useCallback } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { CombinedKPIs } from '../types'; import { CombinedKPIs } from '../types';
import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'; import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor';
import { StockBadge } from './StockBadge';
interface WeeklyGridProps { interface WeeklyGridProps {
data: CombinedKPIs[]; data: CombinedKPIs[];
@@ -10,6 +11,7 @@ interface WeeklyGridProps {
uk: Map<string, number>; uk: Map<string, number>;
}; };
onDrillDown?: (sku: string) => void; onDrillDown?: (sku: string) => void;
stockMap?: Map<string, number>;
} }
type SortConfig = { type SortConfig = {
@@ -51,7 +53,7 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl
); );
}; };
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown }) => { const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown, stockMap }) => {
// Pivot data - memoized // Pivot data - memoized
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
@@ -515,6 +517,9 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
{row.sku || '-'} {row.sku || '-'}
</span> </span>
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span> <span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span>
{stockMap && (
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
)}
</div> </div>
<span className="text-[11px] text-white/70 truncate w-[210px] leading-tight mb-1" title={row.title}>{row.title}</span> <span className="text-[11px] text-white/70 truncate w-[210px] leading-tight mb-1" title={row.title}>{row.title}</span>
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span> <span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
+37
View File
@@ -1619,3 +1619,40 @@ export const calculateForecastViewData = (
}; };
}); });
}; };
export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, number>> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
const stockMap = new Map<string, number>();
// Skip headers (index 0)
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
const rawSku = String(row[0] || '').trim();
if (!rawSku) continue;
// Normalize SKU: Remove trailing EN or DE
const normalizedSku = rawSku.replace(/(DE|EN)$/i, '');
// User requested Column I which is index 8 (After Assembly Orders GMBH)
const stockValue = Number(row[8] || 0);
if (!isNaN(stockValue)) {
const current = stockMap.get(normalizedSku) || 0;
stockMap.set(normalizedSku, current + stockValue);
}
}
return stockMap;
} catch (error) {
console.error("Error processing Stock Excel:", error);
throw error;
}
};