mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:45:23 +02:00
feat: integrate Amazon Vendor Stock data across all views and fix local data loading
This commit is contained in:
@@ -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, processStockExcel } from './services/dataProcessor';
|
import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData, processForecastExcel, calculateForecastViewData, processStockExcel, processVendorStockExcel } 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';
|
||||||
@@ -49,6 +49,7 @@ const App: React.FC = () => {
|
|||||||
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());
|
const [stockMap, setStockMap] = useState<Map<string, number>>(new Map());
|
||||||
|
const [vendorStockMap, setVendorStockMap] = useState<Map<string, { eu: number; uk: number }>>(new Map());
|
||||||
|
|
||||||
// Modal State
|
// Modal State
|
||||||
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
|
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
|
||||||
@@ -143,8 +144,8 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
const handleStockFetch = useCallback(async () => {
|
const handleStockFetch = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
console.log('[App] Fetching stock from /api/fetch-stock...');
|
console.log('[App] Fetching stock from /Item Availability.xlsx...');
|
||||||
const response = await fetch('/api/fetch-stock');
|
const response = await fetch('/Item Availability.xlsx');
|
||||||
if (!response.ok) throw new Error(`Failed to fetch stock: ${response.status}`);
|
if (!response.ok) throw new Error(`Failed to fetch stock: ${response.status}`);
|
||||||
|
|
||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
@@ -156,6 +157,21 @@ const App: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleVendorStockFetch = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
console.log('[App] Fetching vendor stock from /Vendor Stock.xlsx...');
|
||||||
|
const response = await fetch('/Vendor Stock.xlsx');
|
||||||
|
if (!response.ok) throw new Error(`Failed to fetch vendor stock: ${response.status}`);
|
||||||
|
|
||||||
|
const buffer = await response.arrayBuffer();
|
||||||
|
const data = await processVendorStockExcel(buffer);
|
||||||
|
setVendorStockMap(data);
|
||||||
|
console.log('[App] Successfully loaded vendor stock for', data.size, 'ASINs');
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch/parse Vendor Stock", error);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => {
|
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => {
|
||||||
try {
|
try {
|
||||||
@@ -283,6 +299,7 @@ const App: React.FC = () => {
|
|||||||
// 1d. Always fetch Stock data
|
// 1d. Always fetch Stock data
|
||||||
console.log("Fetching Stock data...");
|
console.log("Fetching Stock data...");
|
||||||
handleStockFetch();
|
handleStockFetch();
|
||||||
|
handleVendorStockFetch();
|
||||||
|
|
||||||
// 1e. Fetch Forecast data
|
// 1e. Fetch Forecast data
|
||||||
handleForecastFetch(cachedData || [], cachedData || [], filters);
|
handleForecastFetch(cachedData || [], cachedData || [], filters);
|
||||||
@@ -528,7 +545,7 @@ const App: React.FC = () => {
|
|||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
{/* Force Sync Action */}
|
{/* Force Sync Action */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { handleDataFetch(); handleAdsFetch(); handleTrafficFetch(); }}
|
onClick={() => { handleDataFetch(); handleAdsFetch(); handleTrafficFetch(); handleVendorStockFetch(); }}
|
||||||
disabled={syncing}
|
disabled={syncing}
|
||||||
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-black uppercase tracking-widest transition-all
|
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-black uppercase tracking-widest transition-all
|
||||||
${syncing
|
${syncing
|
||||||
@@ -609,6 +626,7 @@ const App: React.FC = () => {
|
|||||||
hasCustomerFilter={filters.customer.length > 0}
|
hasCustomerFilter={filters.customer.length > 0}
|
||||||
adsData={filteredAdsData}
|
adsData={filteredAdsData}
|
||||||
stockMap={stockMap}
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
top50Ranking={top50Ranking2025}
|
top50Ranking={top50Ranking2025}
|
||||||
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
/>
|
/>
|
||||||
@@ -620,6 +638,7 @@ const App: React.FC = () => {
|
|||||||
data={combinedAdsData}
|
data={combinedAdsData}
|
||||||
onDrillDown={handleSkuDrillDown}
|
onDrillDown={handleSkuDrillDown}
|
||||||
stockMap={stockMap}
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
stockFilter={filters.stock}
|
stockFilter={filters.stock}
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
customerFilters={filters.customer}
|
customerFilters={filters.customer}
|
||||||
@@ -640,6 +659,7 @@ const App: React.FC = () => {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
top50Ranking={top50Ranking2025}
|
top50Ranking={top50Ranking2025}
|
||||||
stockMap={stockMap}
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
stockFilter={filters.stock}
|
stockFilter={filters.stock}
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
@@ -653,6 +673,7 @@ const App: React.FC = () => {
|
|||||||
filters={filters}
|
filters={filters}
|
||||||
top50Ranking={top50Ranking2025}
|
top50Ranking={top50Ranking2025}
|
||||||
stockMap={stockMap}
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
stockFilter={filters.stock}
|
stockFilter={filters.stock}
|
||||||
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
onStockFilterChange={(s) => setFilters(prev => ({ ...prev, stock: s }))}
|
||||||
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
|
||||||
|
|||||||
@@ -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-vendor-stock] Reading Vendor Stock file from local storage...');
|
||||||
|
// Try multiple possible paths to be robust
|
||||||
|
const pathsToTry = [
|
||||||
|
path.join(process.cwd(), 'Vendor Stock.xlsx'),
|
||||||
|
path.join(process.cwd(), 'public', 'Vendor Stock.xlsx'),
|
||||||
|
path.join('/Users/christianvidalwolf/github/CrazeAnalytix', 'Vendor Stock.xlsx')
|
||||||
|
];
|
||||||
|
|
||||||
|
let buffer = null;
|
||||||
|
let foundPath = '';
|
||||||
|
|
||||||
|
for (const p of pathsToTry) {
|
||||||
|
console.log(`[fetch-vendor-stock] Checking path: ${p}`);
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
buffer = fs.readFileSync(p);
|
||||||
|
foundPath = p;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!buffer) {
|
||||||
|
throw new Error(`Vendor Stock file 'Vendor Stock.xlsx' not found in any of: ${pathsToTry.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[fetch-vendor-stock] Successfully read Vendor 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-vendor-stock] Error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { CombinedKPIs, FilterState } from '../types';
|
|||||||
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
import { Top50Badge } from './Top50Badge';
|
import { Top50Badge } from './Top50Badge';
|
||||||
|
import { VendorStockBadge } from './VendorStockBadge';
|
||||||
import { InColumnStockFilter } from './InColumnStockFilter';
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
|
|
||||||
interface AdsPerformanceProps {
|
interface AdsPerformanceProps {
|
||||||
@@ -17,6 +18,7 @@ interface AdsPerformanceProps {
|
|||||||
stockFilter: string[];
|
stockFilter: string[];
|
||||||
onStockFilterChange: (newFilters: string[]) => void;
|
onStockFilterChange: (newFilters: string[]) => void;
|
||||||
top50Mode: 'eu' | 'uk';
|
top50Mode: 'eu' | 'uk';
|
||||||
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits';
|
||||||
@@ -37,7 +39,8 @@ const AdsRow: React.FC<{
|
|||||||
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||||
top50Mode: 'eu' | 'uk';
|
top50Mode: 'eu' | 'uk';
|
||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
}> = React.memo(({ item, top50Ranking, top50Mode, stockMap }) => {
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
|
}> = React.memo(({ item, top50Ranking, top50Mode, stockMap, vendorStockMap }) => {
|
||||||
const asin = item.asin.trim().toUpperCase();
|
const asin = item.asin.trim().toUpperCase();
|
||||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||||
|
|
||||||
@@ -67,6 +70,7 @@ const AdsRow: React.FC<{
|
|||||||
{stockMap && (
|
{stockMap && (
|
||||||
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
)}
|
)}
|
||||||
|
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -88,7 +92,7 @@ const AdsRow: React.FC<{
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange, top50Mode }) => {
|
const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ranking, stockMap, vendorStockMap, stockFilter, onStockFilterChange, top50Mode }) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||||
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({
|
||||||
@@ -393,7 +397,7 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters, top50Ran
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/[0.03]">
|
<tbody className="divide-y divide-white/[0.03]">
|
||||||
{filteredAndSorted.map((p) => (
|
{filteredAndSorted.map((p) => (
|
||||||
<AdsRow key={p.id} item={p} top50Ranking={top50Ranking} top50Mode={top50Mode} stockMap={stockMap} />
|
<AdsRow key={p.id} item={p} top50Ranking={top50Ranking} top50Mode={top50Mode} stockMap={stockMap} vendorStockMap={vendorStockMap} />
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { pivotSalesData, generateXLSX, aggregateForTimeSeries, aggregateForCompa
|
|||||||
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
|
import { DownloadIcon, FunnelIcon, CloseIcon, ChartIcon, TrendingIcon } from './Icons';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
import { Top50Badge } from './Top50Badge';
|
import { Top50Badge } from './Top50Badge';
|
||||||
|
import { VendorStockBadge } from './VendorStockBadge';
|
||||||
|
|
||||||
interface DataGridProps {
|
interface DataGridProps {
|
||||||
data: SalesRecord[] | CombinedKPIs[];
|
data: SalesRecord[] | CombinedKPIs[];
|
||||||
@@ -18,6 +19,7 @@ interface DataGridProps {
|
|||||||
uk: Map<string, number>;
|
uk: Map<string, number>;
|
||||||
};
|
};
|
||||||
top50Mode: 'eu' | 'uk';
|
top50Mode: 'eu' | 'uk';
|
||||||
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortConfig = {
|
type SortConfig = {
|
||||||
@@ -234,7 +236,7 @@ const ExpandableChartCard: React.FC<{ title: string; children: React.ReactNode;
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData, stockMap, top50Ranking, top50Mode }) => {
|
const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData, stockMap, vendorStockMap, top50Ranking, top50Mode }) => {
|
||||||
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);
|
||||||
@@ -1090,7 +1092,10 @@ const DataGrid: React.FC<DataGridProps> = ({ data, hasCustomerFilter, adsData, s
|
|||||||
? <div className="flex items-start gap-2">
|
? <div className="flex items-start gap-2">
|
||||||
<span className="line-clamp-2" title={row.title}>{row.title || '-'}</span>
|
<span className="line-clamp-2" title={row.title}>{row.title || '-'}</span>
|
||||||
{stockMap && (
|
{stockMap && (
|
||||||
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
<>
|
||||||
|
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
|
<VendorStockBadge asin={row.asin} vendorStockMap={vendorStockMap} mode={top50Mode} />
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
: dim === 'asin' || dim === 'sku'
|
: dim === 'asin' || dim === 'sku'
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ProductForecastData, FilterState, CombinedKPIs } from '../types';
|
|||||||
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
import { DownloadIcon, FunnelIcon, TrendingIcon, ChartIcon } from './Icons';
|
||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
import { Top50Badge } from './Top50Badge';
|
import { Top50Badge } from './Top50Badge';
|
||||||
|
import { VendorStockBadge } from './VendorStockBadge';
|
||||||
import { InColumnStockFilter } from './InColumnStockFilter';
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
import { PAN_EU_COUNTRIES } from '../services/dataProcessor';
|
import { PAN_EU_COUNTRIES } from '../services/dataProcessor';
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +20,7 @@ interface ForecastViewProps {
|
|||||||
uk: Map<string, number>;
|
uk: Map<string, number>;
|
||||||
};
|
};
|
||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
stockFilter: string[];
|
stockFilter: string[];
|
||||||
onStockFilterChange: (newFilters: string[]) => void;
|
onStockFilterChange: (newFilters: string[]) => void;
|
||||||
top50Mode: 'eu' | 'uk';
|
top50Mode: 'eu' | 'uk';
|
||||||
@@ -32,7 +34,8 @@ const ForecastRow: React.FC<{
|
|||||||
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
|
||||||
top50Mode: 'eu' | 'uk';
|
top50Mode: 'eu' | 'uk';
|
||||||
stockMap?: Map<string, number>;
|
stockMap?: Map<string, number>;
|
||||||
}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap }) => {
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
|
}> = React.memo(({ item, activeMonths, top50Ranking, top50Mode, stockMap, vendorStockMap }) => {
|
||||||
const asin = item.asin.trim().toUpperCase();
|
const asin = item.asin.trim().toUpperCase();
|
||||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ const ForecastRow: React.FC<{
|
|||||||
{stockMap && (
|
{stockMap && (
|
||||||
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
)}
|
)}
|
||||||
|
<VendorStockBadge asin={item.asin} vendorStockMap={vendorStockMap} mode={top50Mode} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -112,7 +116,7 @@ const ForecastRow: React.FC<{
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange, top50Mode }) => {
|
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, vendorStockMap, stockFilter, onStockFilterChange, top50Mode }) => {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||||
const [displayCount, setDisplayCount] = useState(50);
|
const [displayCount, setDisplayCount] = useState(50);
|
||||||
@@ -246,6 +250,7 @@ const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking
|
|||||||
top50Ranking={top50Ranking}
|
top50Ranking={top50Ranking}
|
||||||
top50Mode={top50Mode}
|
top50Mode={top50Mode}
|
||||||
stockMap={stockMap}
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface VendorStockBadgeProps {
|
||||||
|
asin: string;
|
||||||
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
|
mode: 'eu' | 'uk';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VendorStockBadge: React.FC<VendorStockBadgeProps> = ({ asin, vendorStockMap, mode }) => {
|
||||||
|
if (!vendorStockMap || !asin) return null;
|
||||||
|
|
||||||
|
const stockData = vendorStockMap.get(asin.toUpperCase());
|
||||||
|
if (!stockData) return null;
|
||||||
|
|
||||||
|
const stock = mode === 'uk' ? stockData.uk : stockData.eu;
|
||||||
|
|
||||||
|
// As per user request: "etiqueta será de color gris claro"
|
||||||
|
// Using a light gray/slate theme that fits the dashboard's premium look
|
||||||
|
const themeClasses = "bg-slate-200 text-slate-800 border-slate-300 shadow-sm";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded border text-[10px] font-bold transition-all hover:scale-105 active:scale-95 cursor-default ${themeClasses}`}
|
||||||
|
title={`Stock en Amazon Warehouse (${mode.toUpperCase()}): ${stock}`}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17.5 14c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1zm-11 0c.5 0 1 .5 1 1s-.5 1-1 1-1-.5-1-1 .5-1 1-1z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3 13.5c0 4.7 3.8 8.5 8.5 8.5s8.5-3.8 8.5-8.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M17 18.5c-1.5 1.5-3.5 2.5-5.5 2.5s-4-1-5.5-2.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19 18.5l1.5 1.5M5 18.5L3.5 20"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex flex-col leading-[0.8]">
|
||||||
|
<span className="text-[7px] font-black uppercase opacity-60">Vendor</span>
|
||||||
|
<span>{stock.toLocaleString('de-DE')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,6 +5,7 @@ import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES } from '../servi
|
|||||||
import { StockBadge } from './StockBadge';
|
import { StockBadge } from './StockBadge';
|
||||||
import { InColumnStockFilter } from './InColumnStockFilter';
|
import { InColumnStockFilter } from './InColumnStockFilter';
|
||||||
import { Top50Badge } from './Top50Badge';
|
import { Top50Badge } from './Top50Badge';
|
||||||
|
import { VendorStockBadge } from './VendorStockBadge';
|
||||||
|
|
||||||
interface WeeklyGridProps {
|
interface WeeklyGridProps {
|
||||||
data: CombinedKPIs[];
|
data: CombinedKPIs[];
|
||||||
@@ -17,6 +18,7 @@ interface WeeklyGridProps {
|
|||||||
stockFilter: string[];
|
stockFilter: string[];
|
||||||
onStockFilterChange: (newFilters: string[]) => void;
|
onStockFilterChange: (newFilters: string[]) => void;
|
||||||
customerFilters: string[];
|
customerFilters: string[];
|
||||||
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortConfig = {
|
type SortConfig = {
|
||||||
@@ -48,7 +50,8 @@ const WeeklyRow: React.FC<{
|
|||||||
sortConfig: SortConfig;
|
sortConfig: SortConfig;
|
||||||
renderGrowth: (current: number, previous: number) => React.ReactNode;
|
renderGrowth: (current: number, previous: number) => React.ReactNode;
|
||||||
customerFilters: string[];
|
customerFilters: string[];
|
||||||
}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth, customerFilters }) => {
|
vendorStockMap?: Map<string, { eu: number; uk: number }>;
|
||||||
|
}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth, customerFilters, vendorStockMap }) => {
|
||||||
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
|
||||||
const asin = row.asin.trim().toUpperCase();
|
const asin = row.asin.trim().toUpperCase();
|
||||||
|
|
||||||
@@ -85,6 +88,7 @@ const WeeklyRow: React.FC<{
|
|||||||
{stockMap && (
|
{stockMap && (
|
||||||
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
|
||||||
)}
|
)}
|
||||||
|
<VendorStockBadge asin={row.asin} vendorStockMap={vendorStockMap} mode={top50Mode} />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +133,7 @@ const WeeklyRow: React.FC<{
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange, customerFilters, top50Mode }) => {
|
const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({ data, top50Ranking, onDrillDown, stockMap, vendorStockMap, stockFilter, onStockFilterChange, customerFilters, top50Mode }) => {
|
||||||
// Pivot data - memoized
|
// Pivot data - memoized
|
||||||
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
|
||||||
|
|
||||||
@@ -545,6 +549,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({ da
|
|||||||
weeks={weeks}
|
weeks={weeks}
|
||||||
onDrillDown={onDrillDown}
|
onDrillDown={onDrillDown}
|
||||||
stockMap={stockMap}
|
stockMap={stockMap}
|
||||||
|
vendorStockMap={vendorStockMap}
|
||||||
top50Ranking={top50Ranking}
|
top50Ranking={top50Ranking}
|
||||||
top50Mode={top50Mode}
|
top50Mode={top50Mode}
|
||||||
sortConfig={sortConfig}
|
sortConfig={sortConfig}
|
||||||
|
|||||||
Binary file not shown.
@@ -1694,6 +1694,68 @@ export const calculateForecastViewData = (
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, { eu: number; uk: 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 vendorStockMap = new Map<string, { eu: number; uk: number }>();
|
||||||
|
|
||||||
|
// Find header row (it contains "ASIN")
|
||||||
|
let headerRowIndex = -1;
|
||||||
|
for (let i = 0; i < Math.min(jsonData.length, 20); i++) {
|
||||||
|
if (jsonData[i] && jsonData[i].includes('ASIN')) {
|
||||||
|
headerRowIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerRowIndex === -1) {
|
||||||
|
console.warn("Could not find header row in Vendor Stock Excel");
|
||||||
|
return vendorStockMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip headers
|
||||||
|
// A (0): ASIN
|
||||||
|
// D (3): Marketplace (Country)
|
||||||
|
// P (15): Sellable on hands units
|
||||||
|
for (let i = headerRowIndex + 1; i < jsonData.length; i++) {
|
||||||
|
const row = jsonData[i];
|
||||||
|
if (!row || row.length < 16) continue;
|
||||||
|
|
||||||
|
const asin = String(row[0] || '').trim().toUpperCase();
|
||||||
|
const marketplace = String(row[3] || '').trim().toLowerCase();
|
||||||
|
const stockValue = parseUnits(String(row[15] || '0'));
|
||||||
|
|
||||||
|
if (!asin) continue;
|
||||||
|
|
||||||
|
if (!vendorStockMap.has(asin)) {
|
||||||
|
vendorStockMap.set(asin, { eu: 0, uk: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = vendorStockMap.get(asin)!;
|
||||||
|
|
||||||
|
// Map to UK or EU
|
||||||
|
if (marketplace.includes('uk') || marketplace.includes('kingdom') || marketplace === 'gb') {
|
||||||
|
current.uk += stockValue;
|
||||||
|
} else {
|
||||||
|
// Assume everything else is Pan-EU for now if it's not UK
|
||||||
|
current.eu += stockValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return vendorStockMap;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error processing Vendor Stock Excel:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, number>> => {
|
export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<Map<string, number>> => {
|
||||||
try {
|
try {
|
||||||
const arrayBuffer = fileOrBuffer instanceof File
|
const arrayBuffer = fileOrBuffer instanceof File
|
||||||
|
|||||||
Reference in New Issue
Block a user