Initial production-ready Lucozade audit dashboard
This commit is contained in:
+608
@@ -0,0 +1,608 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { AuthSession, RecoverySession, clearAuthActionFromUrl, getRecoverySessionFromUrl, getStoredSession, refreshSession, signOut } from './services/supabaseAuth';
|
||||
import { fetchGoogleSheetsData, DEFAULT_SPREADSHEET_ID, FetchResult, mergeAuditData } from './services/googleSheets';
|
||||
import { FilterState, MergedStoreAudit, DashboardKPIs, Store } from './types';
|
||||
import { extractWeekOptions, isDateInWeek, isItemInWeek } from './utils/dateUtils';
|
||||
|
||||
import { Header } from './components/Header';
|
||||
import { FilterBar } from './components/FilterBar';
|
||||
import { KPICards } from './components/KPICards';
|
||||
import { AuditStep1Presencia } from './components/AuditStep1Presencia';
|
||||
import { AuditStep2Exhibicion } from './components/AuditStep2Exhibicion';
|
||||
import { AuditStep3Estante } from './components/AuditStep3Estante';
|
||||
import { AuditStep4Inventario } from './components/AuditStep4Inventario';
|
||||
import { StoreTable } from './components/StoreTable';
|
||||
import { StoreAuditModal } from './components/StoreAuditModal';
|
||||
import { SyncSheetModal } from './components/SyncSheetModal';
|
||||
import { ClientTutorialModal } from './components/ClientTutorialModal';
|
||||
import { AuthScreen } from './components/AuthScreen';
|
||||
|
||||
import { LayoutDashboard, Eye, MapPin, BarChart3, PackageCheck, Table, AlertCircle, LoaderCircle, Zap } from 'lucide-react';
|
||||
|
||||
// Helper to deduplicate stores strictly by Customer Name (Store Name)
|
||||
function getUniqueStores(stores: Store[]): Store[] {
|
||||
const map = new Map<string, Store>();
|
||||
stores.forEach(s => {
|
||||
if (!s.customer || !s.customer.trim()) return;
|
||||
const key = s.customer.trim().toLowerCase();
|
||||
if (!map.has(key)) {
|
||||
map.set(key, s);
|
||||
} else {
|
||||
const existing = map.get(key)!;
|
||||
if (!existing.assignedDate && s.assignedDate) {
|
||||
map.set(key, s);
|
||||
}
|
||||
}
|
||||
});
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [authSession, setAuthSession] = useState<AuthSession | null>(null);
|
||||
const [authLoading, setAuthLoading] = useState(true);
|
||||
const [recoverySession, setRecoverySession] = useState<RecoverySession | null>(null);
|
||||
const [recoveryError, setRecoveryError] = useState('');
|
||||
const [spreadsheetId, setSpreadsheetId] = useState(DEFAULT_SPREADSHEET_ID);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
|
||||
const [dataResult, setDataResult] = useState<FetchResult | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'step1' | 'step2' | 'step3' | 'step4' | 'table'>('overview');
|
||||
|
||||
const [selectedAuditItem, setSelectedAuditItem] = useState<MergedStoreAudit | null>(null);
|
||||
const [isSheetModalOpen, setIsSheetModalOpen] = useState(false);
|
||||
const [isTutorialOpen, setIsTutorialOpen] = useState(false);
|
||||
|
||||
// Filters State
|
||||
const [filters, setFilters] = useState<FilterState>({
|
||||
zone: 'all',
|
||||
channel: 'all',
|
||||
customer: 'all',
|
||||
status: 'all',
|
||||
oosStatus: 'all',
|
||||
selectedWeek: 'all',
|
||||
search: ''
|
||||
});
|
||||
|
||||
// Supabase email/password session initialization
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const recovery = await getRecoverySessionFromUrl();
|
||||
if (recovery.session || recovery.error) {
|
||||
if (recovery.error) clearAuthActionFromUrl();
|
||||
if (mounted) {
|
||||
setRecoverySession(recovery.session);
|
||||
setRecoveryError(recovery.error);
|
||||
setAuthSession(null);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const storedSession = await getStoredSession();
|
||||
if (mounted) {
|
||||
setAuthSession(storedSession);
|
||||
setAuthLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Unable to restore the authentication session:', error);
|
||||
if (mounted) {
|
||||
setAuthSession(null);
|
||||
setRecoverySession(null);
|
||||
setRecoveryError('');
|
||||
setAuthLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadData = async (spId: string) => {
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
const res = await fetchGoogleSheetsData(spId, null);
|
||||
setDataResult(res);
|
||||
} catch (e) {
|
||||
console.error('Error loading data:', e);
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (authSession && !recoverySession && !dataResult) {
|
||||
loadData(spreadsheetId);
|
||||
}
|
||||
}, [authSession, recoverySession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authSession) return;
|
||||
|
||||
const refreshIn = Math.max(30_000, (authSession.expires_at * 1000) - Date.now() - 60_000);
|
||||
const timer = window.setTimeout(async () => {
|
||||
const refreshed = await refreshSession(authSession);
|
||||
if (refreshed) {
|
||||
setAuthSession(refreshed);
|
||||
} else {
|
||||
setAuthSession(null);
|
||||
setDataResult(null);
|
||||
}
|
||||
}, refreshIn);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [authSession]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
loadData(spreadsheetId);
|
||||
};
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setFilters({
|
||||
zone: 'all',
|
||||
channel: 'all',
|
||||
customer: 'all',
|
||||
status: 'all',
|
||||
oosStatus: 'all',
|
||||
selectedWeek: 'all',
|
||||
search: ''
|
||||
});
|
||||
};
|
||||
|
||||
// Extract week options for Monday - Sunday selection
|
||||
const weekOptions = useMemo(() => {
|
||||
if (!dataResult) return [];
|
||||
return extractWeekOptions(dataResult.stores, dataResult.responses);
|
||||
}, [dataResult]);
|
||||
|
||||
// Auto-select initial active week when weekOptions load
|
||||
useEffect(() => {
|
||||
if (weekOptions.length > 0 && filters.selectedWeek === 'all') {
|
||||
const target = weekOptions.find(w => w.key.includes('2026-07-20')) || weekOptions[0];
|
||||
setFilters(prev => ({ ...prev, selectedWeek: target.key }));
|
||||
}
|
||||
}, [weekOptions]);
|
||||
|
||||
// Compute Base Merged Items according to selectedWeek filter
|
||||
const baseMergedData = useMemo(() => {
|
||||
if (!dataResult) return [];
|
||||
|
||||
const { stores, responses } = dataResult;
|
||||
const selectedWeek = filters.selectedWeek || 'all';
|
||||
|
||||
if (selectedWeek !== 'all') {
|
||||
// Filter stores by 'week' column or assignedDate falling in selectedWeek
|
||||
const weekStores = stores.filter(s => isItemInWeek(s.week, s.assignedDate, selectedWeek));
|
||||
const uniqueWeekStores = getUniqueStores(weekStores);
|
||||
|
||||
// Distinct count of stores from 'tiendas' sheet for this week
|
||||
const targetStores = uniqueWeekStores.length > 0 ? uniqueWeekStores : getUniqueStores(stores);
|
||||
|
||||
// Filter responses by 'week' column or submissionDate falling in selectedWeek
|
||||
const weekResponses = responses.filter(r => isItemInWeek(r.week, r.submissionDate, selectedWeek));
|
||||
|
||||
return mergeAuditData(targetStores, weekResponses);
|
||||
} else {
|
||||
// All weeks selected: deduplicate stores strictly by Customer name
|
||||
const uniqueStores = getUniqueStores(stores);
|
||||
return mergeAuditData(uniqueStores, responses);
|
||||
}
|
||||
}, [dataResult, filters.selectedWeek]);
|
||||
|
||||
// Filtered Merged Items
|
||||
const filteredMergedData = useMemo(() => {
|
||||
return baseMergedData.filter(item => {
|
||||
// Zone
|
||||
if (filters.zone !== 'all' && item.store.zone !== filters.zone) return false;
|
||||
// Channel
|
||||
if (filters.channel !== 'all' && item.store.customerChannel !== filters.channel) return false;
|
||||
// Customer
|
||||
if (filters.customer !== 'all' && item.store.customer !== filters.customer) return false;
|
||||
// Audit Status
|
||||
if (filters.status !== 'all' && item.status !== filters.status) return false;
|
||||
// OOS Status
|
||||
if (filters.oosStatus === 'Available' && item.response?.isAvailable !== 'Yes') return false;
|
||||
if (filters.oosStatus === 'OOS' && item.response?.isAvailable !== 'No') return false;
|
||||
|
||||
// Search Query
|
||||
if (filters.search.trim()) {
|
||||
const q = filters.search.toLowerCase().trim();
|
||||
const matchesCode = item.store.customerCode.toLowerCase().includes(q);
|
||||
const matchesName = item.store.customer.toLowerCase().includes(q);
|
||||
if (!matchesCode && !matchesName) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}, [baseMergedData, filters]);
|
||||
|
||||
// Dashboard KPIs Calculation
|
||||
const kpis: DashboardKPIs = useMemo(() => {
|
||||
const totalStores = filteredMergedData.length;
|
||||
const visitedItems = filteredMergedData.filter(m => m.status === 'Visited');
|
||||
const visitedStores = visitedItems.length;
|
||||
const pendingStores = totalStores - visitedStores;
|
||||
|
||||
const complianceRate = totalStores > 0 ? (visitedStores / totalStores) * 100 : 0;
|
||||
|
||||
// Total raw response submissions matching the filtered stores in the active week
|
||||
const activeResponses = (filters.selectedWeek && filters.selectedWeek !== 'all')
|
||||
? (dataResult?.responses || []).filter(r => r.submissionDate && isDateInWeek(r.submissionDate, filters.selectedWeek))
|
||||
: (dataResult?.responses || []);
|
||||
|
||||
let totalResponses = visitedStores;
|
||||
if (activeResponses.length > 0) {
|
||||
const storeNamesSet = new Set(filteredMergedData.map(m => m.store.customer.toLowerCase().trim()));
|
||||
const matchingResponses = activeResponses.filter(r => r.customer && storeNamesSet.has(r.customer.toLowerCase().trim()));
|
||||
if (matchingResponses.length > 0) {
|
||||
totalResponses = matchingResponses.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Available and Out of Stock stores in filtered dataset
|
||||
const availableStores = visitedItems.filter(m => m.response?.isAvailable === 'Yes').length;
|
||||
const oosStores = visitedItems.filter(m => m.response?.isAvailable === 'No').length;
|
||||
|
||||
// Denominator for rates based on visited stores
|
||||
const denominator = visitedStores > 0 ? visitedStores : totalStores;
|
||||
const oosRate = denominator > 0 ? (oosStores / denominator) * 100 : 0;
|
||||
const numericDistribution = denominator > 0 ? (availableStores / denominator) * 100 : 0;
|
||||
|
||||
let totalPhysicalInventory = 0;
|
||||
let totalUnitsSoldIn = 0;
|
||||
let priceSum = 0;
|
||||
let priceCount = 0;
|
||||
let facingsSum = 0;
|
||||
let totalIceKickFacings = 0;
|
||||
let totalLucozadeFacings = 0;
|
||||
let totalCategoryFacings = 0;
|
||||
|
||||
const visitedAvailableStores = filteredMergedData.filter(m => m.status === 'Visited' && m.response?.isAvailable === 'Yes');
|
||||
|
||||
filteredMergedData.forEach(item => {
|
||||
totalUnitsSoldIn += item.store.unitsSoldIn || 0;
|
||||
if (item.response) {
|
||||
totalPhysicalInventory += item.response.totalPhysicalInventory || 0;
|
||||
}
|
||||
});
|
||||
|
||||
visitedAvailableStores.forEach(item => {
|
||||
if (item.response) {
|
||||
if (item.response.retailPrice && item.response.retailPrice > 0) {
|
||||
priceSum += item.response.retailPrice;
|
||||
priceCount++;
|
||||
}
|
||||
facingsSum += item.response.facingsIceKick || 0;
|
||||
totalIceKickFacings += item.response.facingsIceKick || 0;
|
||||
totalLucozadeFacings += item.response.facingsLucozadeBrand || 0;
|
||||
totalCategoryFacings += item.response.facingsCategoryTotal || 0;
|
||||
}
|
||||
});
|
||||
|
||||
const avgRetailPrice = priceCount > 0 ? priceSum / priceCount : 0;
|
||||
const avgIceKickFacings = visitedStores > 0 ? facingsSum / visitedStores : 0;
|
||||
const avgBrandShelfShare = totalLucozadeFacings > 0 ? (totalIceKickFacings / totalLucozadeFacings) * 100 : 0;
|
||||
const avgCategoryShelfShare = totalCategoryFacings > 0 ? (totalLucozadeFacings / totalCategoryFacings) * 100 : 0;
|
||||
|
||||
// Placement & Additional Exhibition Metrics (Secondary Display & Gondola End)
|
||||
let mainShelfStores = 0;
|
||||
let secondaryDisplayStores = 0;
|
||||
let gondolaEndStores = 0;
|
||||
let additionalExhibitionStores = 0;
|
||||
|
||||
visitedItems.forEach(m => {
|
||||
const loc = (m.response?.placementLocation || '').toLowerCase();
|
||||
const hasMain = loc.includes('main') || loc.includes('shelf') || loc.includes('estante');
|
||||
const hasSecondary = loc.includes('secondary') || loc.includes('secundaria') || loc.includes('exhibici') || loc.includes('display');
|
||||
const hasGondola = loc.includes('gondola') || loc.includes('góndola') || loc.includes('end') || loc.includes('cabecera') || loc.includes('endcap');
|
||||
|
||||
if (hasMain) mainShelfStores++;
|
||||
if (hasSecondary) secondaryDisplayStores++;
|
||||
if (hasGondola) gondolaEndStores++;
|
||||
if (hasSecondary || hasGondola) additionalExhibitionStores++;
|
||||
});
|
||||
|
||||
const mainShelfRate = denominator > 0 ? (mainShelfStores / denominator) * 100 : 0;
|
||||
const secondaryDisplayRate = denominator > 0 ? (secondaryDisplayStores / denominator) * 100 : 0;
|
||||
const gondolaEndRate = denominator > 0 ? (gondolaEndStores / denominator) * 100 : 0;
|
||||
const additionalExhibitionRate = denominator > 0 ? (additionalExhibitionStores / denominator) * 100 : 0;
|
||||
|
||||
let totalSellOutUnits = 0;
|
||||
let totalSellOutValue = 0;
|
||||
|
||||
visitedItems.forEach(item => {
|
||||
const inv = item.response?.totalPhysicalInventory || 0;
|
||||
const price = item.response?.retailPrice || 0;
|
||||
const sellIn = item.store.unitsSoldIn || 0;
|
||||
const sellOutUnits = Math.max(0, sellIn - inv);
|
||||
const valSellOut = sellOutUnits * price;
|
||||
|
||||
totalSellOutUnits += sellOutUnits;
|
||||
totalSellOutValue += valSellOut;
|
||||
});
|
||||
|
||||
return {
|
||||
totalStores,
|
||||
totalResponses,
|
||||
visitedStores,
|
||||
pendingStores,
|
||||
complianceRate,
|
||||
oosStores,
|
||||
availableStores,
|
||||
oosRate,
|
||||
numericDistribution,
|
||||
totalPhysicalInventory,
|
||||
totalUnitsSoldIn,
|
||||
totalSellOutUnits,
|
||||
totalSellOutValue,
|
||||
avgRetailPrice,
|
||||
avgIceKickFacings,
|
||||
avgBrandShelfShare,
|
||||
avgCategoryShelfShare,
|
||||
mainShelfStores,
|
||||
secondaryDisplayStores,
|
||||
gondolaEndStores,
|
||||
additionalExhibitionStores,
|
||||
mainShelfRate,
|
||||
secondaryDisplayRate,
|
||||
gondolaEndRate,
|
||||
additionalExhibitionRate
|
||||
};
|
||||
}, [filteredMergedData, dataResult]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3 text-sky-700">
|
||||
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-sky-500 to-blue-600 flex items-center justify-center shadow-lg">
|
||||
<Zap className="w-5 h-5 fill-amber-300 text-amber-300" />
|
||||
</div>
|
||||
<LoaderCircle className="w-5 h-5 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authSession || recoverySession || recoveryError) {
|
||||
return (
|
||||
<AuthScreen
|
||||
recoverySession={recoverySession}
|
||||
recoveryError={recoveryError}
|
||||
onAuthenticated={session => {
|
||||
setAuthSession(session);
|
||||
setRecoverySession(null);
|
||||
setRecoveryError('');
|
||||
setDataResult(null);
|
||||
}}
|
||||
onRecoveryComplete={() => {
|
||||
clearAuthActionFromUrl();
|
||||
setRecoverySession(null);
|
||||
setRecoveryError('');
|
||||
setAuthSession(null);
|
||||
setDataResult(null);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const allStores = dataResult?.stores || [];
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut(authSession);
|
||||
setAuthSession(null);
|
||||
setDataResult(null);
|
||||
setRecoverySession(null);
|
||||
setRecoveryError('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 text-slate-800 font-sans flex flex-col">
|
||||
|
||||
{/* Header */}
|
||||
<Header
|
||||
lastSynced={dataResult?.lastSynced || 'Just now'}
|
||||
isSyncing={isSyncing}
|
||||
onRefresh={handleRefresh}
|
||||
onOpenTutorial={() => setIsTutorialOpen(true)}
|
||||
userEmail={authSession.user.email || 'Signed in'}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
|
||||
|
||||
{/* Error notification if sync fails */}
|
||||
{dataResult?.error && (
|
||||
<div className="bg-amber-50 border border-amber-200 text-amber-900 px-4 py-3 rounded-2xl text-xs flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-amber-600 shrink-0" />
|
||||
<span>{dataResult.error}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsSheetModalOpen(true)}
|
||||
className="text-xs font-extrabold underline text-amber-800 hover:text-amber-950 cursor-pointer"
|
||||
>
|
||||
Configure Google Sheet
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Filter Bar */}
|
||||
<FilterBar
|
||||
filters={filters}
|
||||
stores={allStores}
|
||||
weekOptions={weekOptions}
|
||||
onChange={setFilters}
|
||||
onReset={handleResetFilters}
|
||||
/>
|
||||
|
||||
{/* KPI Cards Grid */}
|
||||
<KPICards kpis={kpis} />
|
||||
|
||||
{/* Audit Step Tabs Navigation */}
|
||||
<div className="bg-white/95 backdrop-blur-md rounded-2xl border border-sky-100 p-1.5 flex flex-wrap gap-1.5 shadow-xs sticky top-[172px] sm:top-[175px] z-20">
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('overview')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
activeTab === 'overview'
|
||||
? 'bg-amber-100 text-amber-950 border border-amber-200/80 shadow-2xs font-figtree'
|
||||
: 'text-slate-500 hover:text-slate-900 hover:bg-sky-50'
|
||||
}`}
|
||||
>
|
||||
<LayoutDashboard className="w-3.5 h-3.5 text-amber-800" />
|
||||
<span>Overview</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('step1')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
activeTab === 'step1'
|
||||
? 'bg-amber-100 text-amber-950 border border-amber-200/80 shadow-2xs font-figtree'
|
||||
: 'text-slate-500 hover:text-slate-900 hover:bg-sky-50'
|
||||
}`}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 text-amber-800" />
|
||||
<span>Step 1: Presence (OOS)</span>
|
||||
{kpis.oosStores > 0 && (
|
||||
<span className="bg-rose-600 text-white text-[10px] font-black px-2 py-0.5 rounded-full">
|
||||
{kpis.oosStores}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('step2')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
activeTab === 'step2'
|
||||
? 'bg-amber-100 text-amber-950 border border-amber-200/80 shadow-2xs font-figtree'
|
||||
: 'text-slate-500 hover:text-slate-900 hover:bg-sky-50'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="w-3.5 h-3.5 text-amber-800" />
|
||||
<span>Step 2: Location & POP</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('step3')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
activeTab === 'step3'
|
||||
? 'bg-amber-100 text-amber-950 border border-amber-200/80 shadow-2xs font-figtree'
|
||||
: 'text-slate-500 hover:text-slate-900 hover:bg-sky-50'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5 text-amber-800" />
|
||||
<span>Step 3: Shelf & Share</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('step4')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
activeTab === 'step4'
|
||||
? 'bg-amber-100 text-amber-950 border border-amber-200/80 shadow-2xs font-figtree'
|
||||
: 'text-slate-500 hover:text-slate-900 hover:bg-sky-50'
|
||||
}`}
|
||||
>
|
||||
<PackageCheck className="w-3.5 h-3.5 text-amber-800" />
|
||||
<span>Step 4: Inventory & Backroom</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('table')}
|
||||
className={`flex-1 sm:flex-none inline-flex items-center justify-center gap-2 px-4 py-2 rounded-xl text-xs font-extrabold transition-all cursor-pointer ${
|
||||
activeTab === 'table'
|
||||
? 'bg-sky-100 text-sky-950 shadow-2xs'
|
||||
: 'text-slate-500 hover:text-slate-900 hover:bg-sky-50'
|
||||
}`}
|
||||
>
|
||||
<Table className="w-3.5 h-3.5" />
|
||||
<span>Store Table</span>
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Tab Content Display */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-6">
|
||||
<AuditStep1Presencia
|
||||
mergedData={filteredMergedData}
|
||||
onSelectStore={item => setSelectedAuditItem(item)}
|
||||
/>
|
||||
<StoreTable
|
||||
mergedData={filteredMergedData}
|
||||
onSelectStore={item => setSelectedAuditItem(item)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'step1' && (
|
||||
<AuditStep1Presencia
|
||||
mergedData={filteredMergedData}
|
||||
onSelectStore={item => setSelectedAuditItem(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'step2' && (
|
||||
<AuditStep2Exhibicion
|
||||
mergedData={filteredMergedData}
|
||||
onSelectStore={item => setSelectedAuditItem(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'step3' && (
|
||||
<AuditStep3Estante
|
||||
mergedData={filteredMergedData}
|
||||
onSelectStore={item => setSelectedAuditItem(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'step4' && (
|
||||
<AuditStep4Inventario
|
||||
mergedData={filteredMergedData}
|
||||
onSelectStore={item => setSelectedAuditItem(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'table' && (
|
||||
<StoreTable
|
||||
mergedData={filteredMergedData}
|
||||
onSelectStore={item => setSelectedAuditItem(item)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
{/* Audit Detail Modal */}
|
||||
<StoreAuditModal
|
||||
item={selectedAuditItem}
|
||||
onClose={() => setSelectedAuditItem(null)}
|
||||
/>
|
||||
|
||||
{/* Google Sheets Config Modal */}
|
||||
<SyncSheetModal
|
||||
isOpen={isSheetModalOpen}
|
||||
currentSpreadsheetId={spreadsheetId}
|
||||
source={dataResult?.source || 'fallback_demo'}
|
||||
error={dataResult?.error}
|
||||
onClose={() => setIsSheetModalOpen(false)}
|
||||
onUpdateSpreadsheetId={newId => {
|
||||
setSpreadsheetId(newId);
|
||||
loadData(newId);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Client PPT Presentation / Tutorial Deck Modal */}
|
||||
<ClientTutorialModal
|
||||
isOpen={isTutorialOpen}
|
||||
onClose={() => setIsTutorialOpen(false)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import React from 'react';
|
||||
import { MergedStoreAudit } from '../types';
|
||||
import { CheckCircle2, XCircle, AlertTriangle, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface AuditStep1Props {
|
||||
mergedData: MergedStoreAudit[];
|
||||
onSelectStore: (item: MergedStoreAudit) => void;
|
||||
}
|
||||
|
||||
export const AuditStep1Presencia: React.FC<AuditStep1Props> = ({
|
||||
mergedData,
|
||||
onSelectStore
|
||||
}) => {
|
||||
const visitedItems = mergedData.filter(m => m.status === 'Visited');
|
||||
const availableItems = visitedItems.filter(m => m.response?.isAvailable === 'Yes');
|
||||
const oosItems = visitedItems.filter(m => m.response?.isAvailable === 'No');
|
||||
|
||||
const numericDistPercent = visitedItems.length > 0 ? (availableItems.length / visitedItems.length) * 100 : 0;
|
||||
const oosPercent = visitedItems.length > 0 ? (oosItems.length / visitedItems.length) * 100 : 0;
|
||||
|
||||
// Zone Breakdown
|
||||
const zoneStats = new Map<string, { total: number; available: number; oos: number }>();
|
||||
visitedItems.forEach(item => {
|
||||
const z = item.store.zone || 'Unassigned Zone';
|
||||
const curr = zoneStats.get(z) || { total: 0, available: 0, oos: 0 };
|
||||
curr.total += 1;
|
||||
if (item.response?.isAvailable === 'Yes') curr.available += 1;
|
||||
else curr.oos += 1;
|
||||
zoneStats.set(z, curr);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header Info */}
|
||||
<div className="bg-white border border-sky-100 p-4 sm:p-5 rounded-3xl shadow-2xs">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="bg-amber-100 text-amber-900 border border-amber-200/80 font-bold text-[10px] uppercase tracking-wider px-2.5 py-0.5 rounded-full font-figtree">
|
||||
Step 1
|
||||
</span>
|
||||
<h2 className="text-lg font-extrabold text-slate-900">Presence Validation (First Impression)</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 max-w-2xl font-medium">
|
||||
Immediate POS availability evaluation. If out of stock (OOS), the workflow directs auditors to check the backroom for urgent replenishment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-sky-50/50 px-4 py-2 rounded-2xl border border-sky-100 text-center">
|
||||
<div className="text-xl font-black text-emerald-600 font-mono">{numericDistPercent.toFixed(1)}%</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">Numeric Distribution</div>
|
||||
</div>
|
||||
<div className="bg-sky-50/50 px-4 py-2 rounded-2xl border border-sky-100 text-center">
|
||||
<div className="text-xl font-black text-rose-600 font-mono">{oosPercent.toFixed(1)}%</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">Out of Stock Rate (OOS)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grid Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
{/* Available Card */}
|
||||
<div className="bg-white border border-sky-100 rounded-2xl p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-emerald-700 font-extrabold text-sm">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-600" />
|
||||
<span>Available for Sale Today (Yes)</span>
|
||||
</div>
|
||||
<span className="text-lg font-black text-emerald-700 bg-emerald-50 px-3 py-0.5 rounded-full border border-emerald-200">
|
||||
{availableItems.length} Stores
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
Lucozade Sport Ice Kick is active on the sales floor. Proceed to Step 2 (Placement & Display).
|
||||
</p>
|
||||
<div className="w-full bg-emerald-50 rounded-full h-2 overflow-hidden">
|
||||
<div className="bg-emerald-500 h-2 rounded-full transition-all duration-500" style={{ width: `${numericDistPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* OOS Card */}
|
||||
<div className="bg-white border border-sky-100 rounded-2xl p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2 text-rose-700 font-extrabold text-sm">
|
||||
<XCircle className="w-5 h-5 text-rose-600" />
|
||||
<span>Out of Stock / OOS (No)</span>
|
||||
</div>
|
||||
<span className="text-lg font-black text-rose-700 bg-rose-50 px-3 py-0.5 rounded-full border border-rose-200">
|
||||
{oosItems.length} Stores
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 mb-3">
|
||||
Auditor reported shelf out of stock. <strong className="text-rose-700">System Action:</strong> Priority backroom check required (Step 4).
|
||||
</p>
|
||||
<div className="w-full bg-rose-50 rounded-full h-2 overflow-hidden">
|
||||
<div className="bg-rose-500 h-2 rounded-full transition-all duration-500" style={{ width: `${oosPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* OOS Urgent Action Table */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 shadow-2xs overflow-hidden">
|
||||
<div className="p-4 border-b border-sky-100 bg-sky-50/30 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900 flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-600" />
|
||||
Out of Stock Stores (OOS) - Priority Action
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
List of stores with no product on display. Backroom check required (Step 4).
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-rose-700 bg-rose-50 border border-rose-200 px-2.5 py-1 rounded-full">
|
||||
{oosItems.length} active alerts
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{oosItems.length === 0 ? (
|
||||
<div className="p-8 text-center text-slate-500">
|
||||
<CheckCircle2 className="w-10 h-10 text-emerald-500 mx-auto mb-2" />
|
||||
<p className="text-sm font-bold text-slate-800">Excellent! No out of stock issues reported.</p>
|
||||
<p className="text-xs text-slate-500">All audited stores have product availability today.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-sky-50/60 text-slate-500 font-extrabold uppercase text-[10px] tracking-wider border-b border-sky-100">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Store / Customer</th>
|
||||
<th className="px-4 py-3">Zone</th>
|
||||
<th className="px-4 py-3">Channel</th>
|
||||
<th className="px-4 py-3">Report Date</th>
|
||||
<th className="px-4 py-3">Backroom Inv. (Step 4)</th>
|
||||
<th className="px-4 py-3 text-right">Auditor Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-sky-100/60 text-slate-700">
|
||||
{oosItems.map(item => (
|
||||
<tr key={item.store.customerCode} className="hover:bg-sky-50/30 transition-colors">
|
||||
<td className="px-4 py-3 font-bold text-slate-900">
|
||||
{item.store.customer}
|
||||
<span className="block text-[10px] font-normal text-slate-400">{item.store.address}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-700 font-medium">{item.store.zone}</td>
|
||||
<td className="px-4 py-3 text-slate-700 font-medium">{item.store.customerChannel}</td>
|
||||
<td className="px-4 py-3 text-slate-400">{item.response?.submissionDate}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 font-bold font-mono ${
|
||||
(item.response?.totalPhysicalInventory || 0) > 0 ? 'text-amber-600' : 'text-rose-600'
|
||||
}`}>
|
||||
{item.response?.totalPhysicalInventory || 0} units
|
||||
{(item.response?.totalPhysicalInventory || 0) > 0 ? ' (Stored)' : ' (Total Out)'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => onSelectStore(item)}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-amber-950 bg-amber-100 hover:bg-amber-200/80 border border-amber-200/80 px-3 py-1.5 rounded-xl transition-all shadow-2xs active:scale-95 cursor-pointer font-figtree"
|
||||
>
|
||||
<span>Go to Step 4 (Backroom)</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 text-amber-900" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Zone Breakdown Table */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900 mb-3">OOS Availability by Geographic Zone</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
{Array.from(zoneStats.entries()).map(([zone, stat]) => {
|
||||
const availPct = stat.total > 0 ? (stat.available / stat.total) * 100 : 0;
|
||||
return (
|
||||
<div key={zone} className="border border-sky-100 rounded-xl p-3 bg-sky-50/20">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="font-extrabold text-slate-900 text-xs">{zone}</span>
|
||||
<span className="text-[10px] font-bold text-slate-400">{stat.total} audited</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between text-xs my-1">
|
||||
<span className="text-emerald-700 font-bold">{stat.available} avail.</span>
|
||||
<span className="text-rose-600 font-bold">{stat.oos} OOS</span>
|
||||
</div>
|
||||
<div className="w-full bg-sky-100/60 rounded-full h-1.5 overflow-hidden">
|
||||
<div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${availPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MergedStoreAudit } from '../types';
|
||||
import { Image as ImageIcon, ChevronLeft, ChevronRight, X, ExternalLink, Layers, Tag } from 'lucide-react';
|
||||
|
||||
interface AuditStep2Props {
|
||||
mergedData: MergedStoreAudit[];
|
||||
onSelectStore: (item: MergedStoreAudit) => void;
|
||||
}
|
||||
|
||||
export const AuditStep2Exhibicion: React.FC<AuditStep2Props> = ({
|
||||
mergedData,
|
||||
onSelectStore
|
||||
}) => {
|
||||
const visitedStores = mergedData.filter(m => m.status === 'Visited');
|
||||
const visitedAvailable = mergedData.filter(m => m.status === 'Visited' && m.response?.isAvailable === 'Yes');
|
||||
|
||||
// Store selection filter state
|
||||
const [selectedStoreFilter, setSelectedStoreFilter] = useState<string | null>(null);
|
||||
|
||||
// Filter stores according to active store selection if clicked
|
||||
const activeVisitedStores = selectedStoreFilter
|
||||
? visitedStores.filter(item => item.store.customer === selectedStoreFilter)
|
||||
: visitedStores;
|
||||
|
||||
const activeVisitedAvailable = selectedStoreFilter
|
||||
? visitedAvailable.filter(item => item.store.customer === selectedStoreFilter)
|
||||
: visitedAvailable;
|
||||
|
||||
// Placement breakdown across reported/visited stores
|
||||
let mainShelfCount = 0;
|
||||
let secondaryDisplayCount = 0;
|
||||
let endcapCount = 0;
|
||||
|
||||
activeVisitedStores.forEach(item => {
|
||||
const loc = (item.response?.placementLocation || '').toLowerCase();
|
||||
if (loc.includes('estante') || loc.includes('main') || loc.includes('shelf')) mainShelfCount++;
|
||||
if (loc.includes('exhibici') || loc.includes('display') || loc.includes('secondary') || loc.includes('secundaria')) secondaryDisplayCount++;
|
||||
if (loc.includes('cabecera') || loc.includes('endcap') || loc.includes('gondola') || loc.includes('góndola') || loc.includes('end')) endcapCount++;
|
||||
});
|
||||
|
||||
const totalLocs = activeVisitedStores.length || 1;
|
||||
|
||||
// Helper to extract URLs
|
||||
const extractUrls = (list: string[] | undefined): string[] => {
|
||||
if (!list) return [];
|
||||
return list.flatMap(s => s.split(/[\r\n,;\s]+/).map(url => url.trim())).filter(url => url.startsWith('http://') || url.startsWith('https://'));
|
||||
};
|
||||
|
||||
// 1. Shelf / Display Photo Gallery
|
||||
const shelfPhotoGallery = activeVisitedStores.flatMap(item => {
|
||||
const urls = extractUrls(item.response?.shelfPhotos);
|
||||
return urls.map(url => ({
|
||||
url,
|
||||
store: item.store.customer,
|
||||
type: item.response?.placementLocation || 'Display / Shelf',
|
||||
date: item.response?.submissionDate || '',
|
||||
auditItem: item
|
||||
}));
|
||||
});
|
||||
|
||||
// 2. POP Material Photo Gallery
|
||||
const popPhotoGallery = activeVisitedStores.flatMap(item => {
|
||||
const urls = extractUrls(item.response?.popPhotos);
|
||||
return urls.map(url => ({
|
||||
url,
|
||||
store: item.store.customer,
|
||||
type: item.response?.popVisible || 'POP Material',
|
||||
date: item.response?.submissionDate || '',
|
||||
auditItem: item
|
||||
}));
|
||||
});
|
||||
|
||||
// Pagination States for Both Galleries
|
||||
const [shelfCurrentPage, setShelfCurrentPage] = useState(1);
|
||||
const [popCurrentPage, setPopCurrentPage] = useState(1);
|
||||
const ITEMS_PER_PAGE = 6; // 3x2 grid per gallery
|
||||
|
||||
const shelfTotalPages = Math.ceil(shelfPhotoGallery.length / ITEMS_PER_PAGE) || 1;
|
||||
const paginatedShelfPhotos = shelfPhotoGallery.slice((shelfCurrentPage - 1) * ITEMS_PER_PAGE, shelfCurrentPage * ITEMS_PER_PAGE);
|
||||
|
||||
const popTotalPages = Math.ceil(popPhotoGallery.length / ITEMS_PER_PAGE) || 1;
|
||||
const paginatedPopPhotos = popPhotoGallery.slice((popCurrentPage - 1) * ITEMS_PER_PAGE, popCurrentPage * ITEMS_PER_PAGE);
|
||||
|
||||
// Active Photo Lightbox
|
||||
const [activePhoto, setActivePhoto] = useState<{ url: string; store: string; type: string; date: string; auditItem: MergedStoreAudit } | null>(null);
|
||||
|
||||
const handleRowClick = (customerName: string) => {
|
||||
if (selectedStoreFilter === customerName) {
|
||||
setSelectedStoreFilter(null);
|
||||
} else {
|
||||
setSelectedStoreFilter(customerName);
|
||||
}
|
||||
setShelfCurrentPage(1);
|
||||
setPopCurrentPage(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header Info */}
|
||||
<div className="bg-white border border-sky-100 p-4 sm:p-5 rounded-3xl shadow-2xs">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="bg-amber-100 text-amber-900 border border-amber-200/80 font-bold text-[10px] uppercase tracking-wider px-2.5 py-0.5 rounded-full font-figtree">
|
||||
Step 2
|
||||
</span>
|
||||
<h2 className="text-lg font-extrabold text-slate-900">Placement & Display (Store Mapping)</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 max-w-2xl font-medium">
|
||||
Assessment of Share of Display, physical location, and execution of POP materials in active stores.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-sky-50/50 px-4 py-2 rounded-2xl border border-sky-100 text-center shrink-0">
|
||||
<div className="text-xl font-black text-amber-600 font-mono">{visitedAvailable.length}</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">Stores with Display</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Placement Distribution */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
{/* Main Shelf */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Main Shelf
|
||||
</span>
|
||||
<span className="text-xs font-black text-amber-900 bg-amber-100 px-2.5 py-0.5 rounded-full">
|
||||
{((mainShelfCount / totalLocs) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-slate-900 mb-1">{mainShelfCount} stores</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium mb-3">Standard placement in beverage section</p>
|
||||
<div className="w-full bg-sky-100/60 rounded-full h-1.5 overflow-hidden">
|
||||
<div className="bg-amber-400 h-1.5 rounded-full" style={{ width: `${(mainShelfCount / totalLocs) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Secondary Display */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Secondary Display
|
||||
</span>
|
||||
<span className="text-xs font-black text-emerald-700 bg-emerald-50 px-2.5 py-0.5 rounded-full border border-emerald-200">
|
||||
{((secondaryDisplayCount / totalLocs) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-emerald-600 mb-1">{secondaryDisplayCount} stores</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium mb-3">Promotional island, auxiliary cooler, dump bin</p>
|
||||
<div className="w-full bg-sky-100/60 rounded-full h-1.5 overflow-hidden">
|
||||
<div className="bg-emerald-500 h-1.5 rounded-full" style={{ width: `${(secondaryDisplayCount / totalLocs) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Endcap */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Endcap
|
||||
</span>
|
||||
<span className="text-xs font-black text-sky-700 bg-sky-50 px-2.5 py-0.5 rounded-full border border-sky-200">
|
||||
{((endcapCount / totalLocs) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-sky-600 mb-1">{endcapCount} stores</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium mb-3">High-visibility endcap placement</p>
|
||||
<div className="w-full bg-sky-100/60 rounded-full h-1.5 overflow-hidden">
|
||||
<div className="bg-sky-500 h-1.5 rounded-full" style={{ width: `${(endcapCount / totalLocs) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Main Layout: Table on Left + Two Stacked Galleries on Right */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
|
||||
|
||||
{/* Left Column: Store Mapping Table (7 cols) */}
|
||||
<div className="lg:col-span-7 bg-white rounded-2xl border border-sky-100 shadow-2xs overflow-hidden flex flex-col h-full">
|
||||
<div className="p-3.5 border-b border-sky-100 bg-sky-50/40 flex items-center justify-between gap-2 shrink-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900">
|
||||
Display & Promotional Material (POP) Mapping
|
||||
</h3>
|
||||
{selectedStoreFilter && (
|
||||
<span className="inline-flex items-center gap-1 bg-amber-100 text-amber-900 border border-amber-300 text-[10px] font-black px-2 py-0.5 rounded-full">
|
||||
Filtered: {selectedStoreFilter}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedStoreFilter(null);
|
||||
setShelfCurrentPage(1);
|
||||
setPopCurrentPage(1);
|
||||
}}
|
||||
className="p-0.5 hover:bg-amber-200 rounded-full cursor-pointer"
|
||||
title="Clear filter"
|
||||
>
|
||||
<X className="w-3 h-3 text-amber-800" />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||
Click a store row to filter photo galleries and metrics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedStoreFilter && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedStoreFilter(null);
|
||||
setShelfCurrentPage(1);
|
||||
setPopCurrentPage(1);
|
||||
}}
|
||||
className="text-[11px] font-extrabold text-amber-700 hover:text-amber-900 underline cursor-pointer shrink-0"
|
||||
>
|
||||
View All ({visitedAvailable.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto overflow-y-auto max-h-[640px] scrollbar-thin flex-1">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-sky-50 text-slate-500 font-extrabold uppercase text-[10px] tracking-wider border-b border-sky-100 sticky top-0 z-10 shadow-2xs">
|
||||
<tr>
|
||||
<th className="px-3.5 py-2.5">Store / Customer</th>
|
||||
<th className="px-3 py-2.5">Zone</th>
|
||||
<th className="px-3 py-2.5">Product Location</th>
|
||||
<th className="px-3 py-2.5">POP Material</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-sky-100/60 text-slate-700">
|
||||
{visitedAvailable.map(item => {
|
||||
const isSelected = selectedStoreFilter === item.store.customer;
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={item.store.customerCode}
|
||||
onClick={() => handleRowClick(item.store.customer)}
|
||||
className={`cursor-pointer transition-all ${
|
||||
isSelected
|
||||
? 'bg-amber-100/70 border-l-4 border-l-amber-500 font-bold text-slate-950'
|
||||
: 'hover:bg-sky-50/60'
|
||||
}`}
|
||||
>
|
||||
<td className="px-3.5 py-2.5 font-bold text-slate-900 flex items-center justify-between gap-2">
|
||||
<span>{item.store.customer}</span>
|
||||
{isSelected && (
|
||||
<span className="text-[9px] bg-amber-400 text-slate-950 px-1.5 py-0.5 rounded-full font-black uppercase tracking-wider shrink-0">
|
||||
Filtered
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600 font-medium">{item.store.zone}</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={`inline-flex items-center font-bold px-2 py-0.5 rounded-lg text-[11px] ${
|
||||
isSelected ? 'bg-amber-200 text-amber-950' : 'bg-sky-50 border border-sky-100 text-slate-800'
|
||||
}`}>
|
||||
{item.response?.placementLocation || 'No Location'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-slate-600">
|
||||
{item.response?.popVisible || 'None'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Two Stacked Galleries (5 cols) */}
|
||||
<div className="lg:col-span-5 flex flex-col gap-4 justify-between">
|
||||
|
||||
{/* Top Gallery: Display & Shelf Photos */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs flex flex-col justify-between flex-1">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3 border-b border-sky-100 pb-2.5">
|
||||
<div>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900 flex items-center gap-1.5">
|
||||
<Layers className="w-4 h-4 text-sky-600" />
|
||||
Display & Shelf Photos
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||
Shelf & display photos from floor audit
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-sky-800 bg-sky-50 border border-sky-100 px-2.5 py-0.5 rounded-full">
|
||||
{shelfPhotoGallery.length} photo(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{shelfPhotoGallery.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400 text-xs font-medium">
|
||||
No shelf photos attached for this selection.
|
||||
</div>
|
||||
) : (
|
||||
/* 3-Column Image Thumbnail Grid */
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{paginatedShelfPhotos.map((p, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setActivePhoto(p)}
|
||||
className="group relative rounded-xl border border-slate-200/80 bg-slate-900 overflow-hidden cursor-pointer aspect-[4/3] shadow-2xs hover:shadow-md hover:border-sky-500 transition-all flex flex-col justify-end"
|
||||
>
|
||||
<img
|
||||
src={p.url}
|
||||
alt={p.store}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-slate-950/20 to-transparent pointer-events-none" />
|
||||
<div className="relative z-10 p-1.5 text-white">
|
||||
<p className="text-[9px] font-extrabold truncate drop-shadow-xs" title={p.store}>
|
||||
{p.store}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls for Shelf Photos */}
|
||||
{shelfPhotoGallery.length > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-sky-100 pt-2.5 mt-3 bg-slate-50/60 p-1.5 rounded-xl">
|
||||
<button
|
||||
disabled={shelfCurrentPage === 1}
|
||||
onClick={() => setShelfCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
className="flex items-center gap-1 text-[11px] font-bold text-slate-700 bg-white border border-slate-200 px-2.5 py-1 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 transition-colors cursor-pointer shadow-2xs"
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<span className="text-[11px] font-bold text-slate-700 font-mono">
|
||||
Page <span className="text-sky-700">{shelfCurrentPage}</span> of {shelfTotalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
disabled={shelfCurrentPage === shelfTotalPages}
|
||||
onClick={() => setShelfCurrentPage(prev => Math.min(shelfTotalPages, prev + 1))}
|
||||
className="flex items-center gap-1 text-[11px] font-bold text-slate-700 bg-white border border-slate-200 px-2.5 py-1 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 transition-colors cursor-pointer shadow-2xs"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Gallery: POP Material Photos */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs flex flex-col justify-between flex-1">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3 border-b border-sky-100 pb-2.5">
|
||||
<div>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900 flex items-center gap-1.5">
|
||||
<Tag className="w-4 h-4 text-amber-600" />
|
||||
POP Material Photos
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||
Promotional POP photos (posters, wobblers, shelf strips)
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-amber-800 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-full">
|
||||
{popPhotoGallery.length} photo(s)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{popPhotoGallery.length === 0 ? (
|
||||
<div className="text-center py-8 text-slate-400 text-xs font-medium">
|
||||
No POP material photos attached for this selection.
|
||||
</div>
|
||||
) : (
|
||||
/* 3-Column Image Thumbnail Grid */
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{paginatedPopPhotos.map((p, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setActivePhoto(p)}
|
||||
className="group relative rounded-xl border border-slate-200/80 bg-slate-900 overflow-hidden cursor-pointer aspect-[4/3] shadow-2xs hover:shadow-md hover:border-amber-500 transition-all flex flex-col justify-end"
|
||||
>
|
||||
<img
|
||||
src={p.url}
|
||||
alt={p.store}
|
||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-slate-950/20 to-transparent pointer-events-none" />
|
||||
<div className="relative z-10 p-1.5 text-white">
|
||||
<p className="text-[9px] font-extrabold truncate drop-shadow-xs" title={p.store}>
|
||||
{p.store}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls for POP Photos */}
|
||||
{popPhotoGallery.length > 0 && (
|
||||
<div className="flex items-center justify-between border-t border-sky-100 pt-2.5 mt-3 bg-slate-50/60 p-1.5 rounded-xl">
|
||||
<button
|
||||
disabled={popCurrentPage === 1}
|
||||
onClick={() => setPopCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
className="flex items-center gap-1 text-[11px] font-bold text-slate-700 bg-white border border-slate-200 px-2.5 py-1 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 transition-colors cursor-pointer shadow-2xs"
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<span className="text-[11px] font-bold text-slate-700 font-mono">
|
||||
Page <span className="text-amber-700">{popCurrentPage}</span> of {popTotalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
disabled={popCurrentPage === popTotalPages}
|
||||
onClick={() => setPopCurrentPage(prev => Math.min(popTotalPages, prev + 1))}
|
||||
className="flex items-center gap-1 text-[11px] font-bold text-slate-700 bg-white border border-slate-200 px-2.5 py-1 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed hover:bg-slate-100 transition-colors cursor-pointer shadow-2xs"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Lightbox Modal */}
|
||||
{activePhoto && (
|
||||
<div
|
||||
onClick={() => setActivePhoto(null)}
|
||||
className="fixed inset-0 z-50 bg-slate-950/80 backdrop-blur-xs flex items-center justify-center p-4 cursor-pointer"
|
||||
>
|
||||
<div
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="relative max-w-3xl w-full bg-white border border-sky-100 rounded-3xl overflow-hidden p-4 shadow-2xl space-y-3 cursor-default"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-sky-100 pb-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-extrabold text-slate-900">{activePhoto.store}</h4>
|
||||
<p className="text-xs text-sky-700 font-bold mt-0.5">{activePhoto.type} • {activePhoto.date}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={activePhoto.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="p-1.5 rounded-full bg-slate-100 hover:bg-slate-200 text-slate-700 transition-colors"
|
||||
title="Open original image"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => setActivePhoto(null)}
|
||||
className="p-1.5 rounded-full bg-slate-100 hover:bg-slate-200 text-slate-700 transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950 rounded-2xl overflow-hidden flex items-center justify-center min-h-[300px] max-h-[70vh]">
|
||||
<img
|
||||
src={activePhoto.url}
|
||||
alt={activePhoto.store}
|
||||
className="max-w-full max-h-[65vh] object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-xs pt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActivePhoto(null);
|
||||
onSelectStore(activePhoto.auditItem);
|
||||
}}
|
||||
className="font-bold text-sky-700 hover:underline cursor-pointer"
|
||||
>
|
||||
View full store report →
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActivePhoto(null)}
|
||||
className="font-bold text-slate-500 hover:text-slate-800 cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import React from 'react';
|
||||
import { MergedStoreAudit } from '../types';
|
||||
import { Tag, BarChart2, Layers, Repeat, ArrowDownRight, Award } from 'lucide-react';
|
||||
|
||||
interface AuditStep3Props {
|
||||
mergedData: MergedStoreAudit[];
|
||||
onSelectStore: (item: MergedStoreAudit) => void;
|
||||
}
|
||||
|
||||
export const AuditStep3Estante: React.FC<AuditStep3Props> = ({
|
||||
mergedData,
|
||||
onSelectStore
|
||||
}) => {
|
||||
const visitedAvailable = mergedData.filter(m => m.status === 'Visited' && m.response?.isAvailable === 'Yes');
|
||||
|
||||
// Calculating averages
|
||||
let totalIceKickFacings = 0;
|
||||
let totalLucozadeFacings = 0;
|
||||
let totalCategoryFacings = 0;
|
||||
let totalPrice = 0;
|
||||
let priceCount = 0;
|
||||
|
||||
visitedAvailable.forEach(item => {
|
||||
const res = item.response;
|
||||
if (res) {
|
||||
totalIceKickFacings += res.facingsIceKick || 0;
|
||||
totalLucozadeFacings += res.facingsLucozadeBrand || 0;
|
||||
totalCategoryFacings += res.facingsCategoryTotal || 0;
|
||||
if (res.retailPrice && res.retailPrice > 0) {
|
||||
totalPrice += res.retailPrice;
|
||||
priceCount++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const avgIceKickFacings = visitedAvailable.length > 0 ? totalIceKickFacings / visitedAvailable.length : 0;
|
||||
const avgLucozadeFacings = visitedAvailable.length > 0 ? totalLucozadeFacings / visitedAvailable.length : 0;
|
||||
const avgCategoryFacings = visitedAvailable.length > 0 ? totalCategoryFacings / visitedAvailable.length : 0;
|
||||
const avgPrice = priceCount > 0 ? totalPrice / priceCount : 0;
|
||||
|
||||
// Overall Shares
|
||||
const brandShare = totalLucozadeFacings > 0 ? (totalIceKickFacings / totalLucozadeFacings) * 100 : 0;
|
||||
const categoryShare = totalCategoryFacings > 0 ? (totalLucozadeFacings / totalCategoryFacings) * 100 : 0;
|
||||
const iceKickCategoryShare = totalCategoryFacings > 0 ? (totalIceKickFacings / totalCategoryFacings) * 100 : 0;
|
||||
|
||||
// SKU Substitution Analysis
|
||||
const substitutionList = visitedAvailable
|
||||
.map(i => ({
|
||||
store: i.store.customer,
|
||||
sub: i.response?.skuSubstitution || 'N/A',
|
||||
iceKickFacings: i.response?.facingsIceKick || 0,
|
||||
price: i.response?.retailPrice || 0
|
||||
}))
|
||||
.filter(i => i.sub && !i.sub.toLowerCase().includes('ninguna') && !i.sub.toLowerCase().includes('n/a'));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header Info */}
|
||||
<div className="bg-white border border-sky-100 p-4 sm:p-5 rounded-3xl shadow-2xs">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="bg-amber-100 text-amber-900 border border-amber-200/80 font-bold text-[10px] uppercase tracking-wider px-2.5 py-0.5 rounded-full font-figtree">
|
||||
Step 3
|
||||
</span>
|
||||
<h2 className="text-lg font-extrabold text-slate-900">Main Shelf Measurement (Detailed Analysis)</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 max-w-2xl font-medium">
|
||||
Detailed audit of consumer price, facing counts, Share of Brand Shelf, Share of Category, and competitor SKU substitution.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-sky-50/50 px-3.5 py-2 rounded-2xl border border-sky-100 text-center">
|
||||
<div className="text-lg font-black text-emerald-600 font-mono">${avgPrice.toFixed(2)}</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">Avg Price</div>
|
||||
</div>
|
||||
<div className="bg-sky-50/50 px-3.5 py-2 rounded-2xl border border-sky-100 text-center">
|
||||
<div className="text-lg font-black text-amber-600 font-mono">{brandShare.toFixed(1)}%</div>
|
||||
<div className="text-[10px] uppercase tracking-wider text-slate-400 font-bold">Brand Share</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Facing & Share Metric Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
{/* Ice Kick Facings */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Ice Kick Facings
|
||||
</span>
|
||||
<span className="p-1.5 rounded-xl bg-amber-50 text-amber-600 border border-amber-200">
|
||||
<BarChart2 className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-slate-900 mb-0.5">
|
||||
{avgIceKickFacings.toFixed(1)} <span className="text-xs font-semibold text-slate-400">facings/store</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">Total accum: {totalIceKickFacings} facings</p>
|
||||
</div>
|
||||
|
||||
{/* Share of Brand Shelf */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs font-figtree">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Share of Brand Shelf
|
||||
</span>
|
||||
<span className="p-1.5 rounded-xl bg-sky-50 text-sky-600 border border-sky-200">
|
||||
<Layers className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-sky-600 mb-0.5 font-geist">
|
||||
{brandShare.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">
|
||||
Ice Kick / Total Lucozade Brand ({totalLucozadeFacings} facings)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Share of Category Shelf */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs font-figtree">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Share of Category
|
||||
</span>
|
||||
<span className="p-1.5 rounded-xl bg-amber-50 text-amber-600 border border-amber-200">
|
||||
<Award className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-bold text-amber-600 mb-0.5 font-geist">
|
||||
{categoryShare.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">
|
||||
Lucozade / Total Category ({totalCategoryFacings} facings)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Retail Price */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Avg Retail Price
|
||||
</span>
|
||||
<span className="p-1.5 rounded-xl bg-emerald-50 text-emerald-600 border border-emerald-200">
|
||||
<Tag className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-emerald-600 font-mono mb-0.5">
|
||||
${avgPrice.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">Main shelf retail price</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Share of Shelf Breakdown Bar */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900 mb-1">
|
||||
Energy & Sports Drinks Shelf Composition
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">
|
||||
Total facing distribution across Lucozade Ice Kick, rest of Lucozade, and competitors.
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-bold text-slate-800 mb-1">
|
||||
<span>Lucozade Ice Kick ({totalIceKickFacings} facings)</span>
|
||||
<span className="text-sky-600 font-extrabold">{iceKickCategoryShare.toFixed(1)}% of category</span>
|
||||
</div>
|
||||
<div className="w-full bg-sky-100/60 rounded-full h-3 overflow-hidden">
|
||||
<div className="bg-sky-500 h-3 rounded-full" style={{ width: `${iceKickCategoryShare}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-bold text-slate-800 mb-1">
|
||||
<span>Total Lucozade Brand ({totalLucozadeFacings} facings)</span>
|
||||
<span className="text-amber-600 font-extrabold">{categoryShare.toFixed(1)}% of category</span>
|
||||
</div>
|
||||
<div className="w-full bg-sky-100/60 rounded-full h-3 overflow-hidden">
|
||||
<div className="bg-amber-400 h-3 rounded-full" style={{ width: `${categoryShare}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-xs font-bold text-slate-800 mb-1">
|
||||
<span>Competitors / Total Category ({totalCategoryFacings} facings)</span>
|
||||
<span className="text-slate-400 font-extrabold">100% of shelf</span>
|
||||
</div>
|
||||
<div className="w-full bg-sky-100/60 rounded-full h-3 overflow-hidden">
|
||||
<div className="bg-slate-300 h-3 rounded-full" style={{ width: '100%' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* SKU Substitution Tracking Table */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 shadow-2xs overflow-hidden">
|
||||
<div className="p-4 border-b border-sky-100 bg-sky-50/30 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900 flex items-center gap-2">
|
||||
<Repeat className="w-4 h-4 text-sky-600" />
|
||||
SKU Substitution Tracking (Space Gain)
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Which competitor brands or SKUs surrendered shelf space for Ice Kick placement?
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-bold text-amber-800 bg-amber-50 border border-amber-200 px-2.5 py-1 rounded-full">
|
||||
{substitutionList.length} space gain(s) recorded
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-sky-50/60 text-slate-500 font-extrabold uppercase text-[10px] tracking-wider border-b border-sky-100">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Store / Customer</th>
|
||||
<th className="px-4 py-3">Surrendered Brand / SKU</th>
|
||||
<th className="px-4 py-3">Facings Gained by Ice Kick</th>
|
||||
<th className="px-4 py-3">Shelf Price</th>
|
||||
<th className="px-4 py-3 text-right">View Report</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-sky-100/60 text-slate-700">
|
||||
{visitedAvailable.map(item => (
|
||||
<tr key={item.store.customerCode} className="hover:bg-sky-50/30 transition-colors">
|
||||
<td className="px-4 py-3 font-bold text-slate-900">{item.store.customer}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 font-bold text-amber-900 bg-amber-50 border border-amber-200 px-2.5 py-0.5 rounded-lg">
|
||||
<ArrowDownRight className="w-3.5 h-3.5 text-amber-600" />
|
||||
{item.response?.skuSubstitution || 'N/A'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-extrabold text-emerald-600 font-mono">
|
||||
+{item.response?.facingsIceKick || 0} facings
|
||||
</td>
|
||||
<td className="px-4 py-3 font-bold text-slate-900 font-mono">
|
||||
${(item.response?.retailPrice || 0).toFixed(2)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => onSelectStore(item)}
|
||||
className="text-xs font-bold text-sky-700 hover:text-sky-900 hover:underline cursor-pointer"
|
||||
>
|
||||
View Details →
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import React from 'react';
|
||||
import { MergedStoreAudit } from '../types';
|
||||
import { PackageCheck, DollarSign, Truck, ShoppingBag } from 'lucide-react';
|
||||
|
||||
interface AuditStep4Props {
|
||||
mergedData: MergedStoreAudit[];
|
||||
onSelectStore: (item: MergedStoreAudit) => void;
|
||||
}
|
||||
|
||||
export const AuditStep4Inventario: React.FC<AuditStep4Props> = ({
|
||||
mergedData,
|
||||
onSelectStore
|
||||
}) => {
|
||||
const visitedItems = mergedData.filter(m => m.status === 'Visited');
|
||||
|
||||
let totalPhysicalInventory = 0;
|
||||
let totalSellInUnits = 0;
|
||||
let totalSellOutUnits = 0;
|
||||
let totalSellOutValue = 0;
|
||||
|
||||
visitedItems.forEach(item => {
|
||||
const inv = item.response?.totalPhysicalInventory || 0;
|
||||
const price = item.response?.retailPrice || 0;
|
||||
const sellIn = item.store.unitsSoldIn || 0;
|
||||
const sellOutUnits = Math.max(0, sellIn - inv);
|
||||
const valSellOut = sellOutUnits * price;
|
||||
|
||||
totalPhysicalInventory += inv;
|
||||
totalSellInUnits += sellIn;
|
||||
totalSellOutUnits += sellOutUnits;
|
||||
totalSellOutValue += valSellOut;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Header Info */}
|
||||
<div className="bg-white border border-sky-100 p-4 sm:p-5 rounded-3xl shadow-2xs">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="bg-amber-100 text-amber-900 border border-amber-200/80 font-bold text-[10px] uppercase tracking-wider px-2.5 py-0.5 rounded-full font-figtree">
|
||||
Step 4
|
||||
</span>
|
||||
<h2 className="text-lg font-extrabold text-slate-900">Inventory & Closing (Store Manager Alignment)</h2>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 max-w-2xl font-medium">
|
||||
Final physical count combining sales floor units and backroom stock.
|
||||
Volume Sell-Out is calculated by subtracting physical inventory from dispatched Sell-In.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="bg-sky-50/50 px-3.5 py-2 rounded-2xl border border-sky-100 text-center">
|
||||
<div className="text-lg font-black text-slate-900 font-mono">{totalPhysicalInventory.toLocaleString()}</div>
|
||||
<div className="text-[9px] uppercase tracking-wider text-slate-400 font-bold">Physical Inventory</div>
|
||||
</div>
|
||||
<div className="bg-sky-50/50 px-3.5 py-2 rounded-2xl border border-sky-100 text-center">
|
||||
<div className="text-lg font-black text-sky-600 font-mono">{totalSellOutUnits.toLocaleString()}</div>
|
||||
<div className="text-[9px] uppercase tracking-wider text-slate-400 font-bold">Volume Sell-Out (Units)</div>
|
||||
</div>
|
||||
<div className="bg-sky-50/50 px-3.5 py-2 rounded-2xl border border-sky-100 text-center">
|
||||
<div className="text-lg font-black text-emerald-600 font-mono">${totalSellOutValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
|
||||
<div className="text-[9px] uppercase tracking-wider text-slate-400 font-bold">Value Sell-Out ($)</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
{/* 1. Physical Inventory */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Physical Inventory (Floor + Backroom)
|
||||
</span>
|
||||
<div className="p-1.5 rounded-xl bg-amber-50 text-amber-600 border border-amber-200">
|
||||
<PackageCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-slate-900 font-mono mb-0.5">
|
||||
{totalPhysicalInventory.toLocaleString()} <span className="text-xs font-bold text-slate-400 font-sans">units</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">
|
||||
Physical count reported by auditor in Step 4
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 2. Dispatched Sell-In */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Dispatched Sell-In (Units Sold In)
|
||||
</span>
|
||||
<div className="p-1.5 rounded-xl bg-sky-50 text-sky-600 border border-sky-200">
|
||||
<Truck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-sky-600 font-mono mb-0.5">
|
||||
{totalSellInUnits.toLocaleString()} <span className="text-xs font-bold text-slate-400 font-sans">units</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">
|
||||
Provided by the <span className="font-semibold text-slate-700">tiendas</span> sheet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 3. Calculated Volume Sell-Out */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Volume Sell-Out (Units)
|
||||
</span>
|
||||
<div className="p-1.5 rounded-xl bg-amber-50 text-amber-700 border border-amber-200">
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-amber-600 font-mono mb-0.5">
|
||||
{totalSellOutUnits.toLocaleString()} <span className="text-xs font-bold text-slate-400 font-sans">units</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">
|
||||
Calculated (Dispatched Sell-In - Physical Inv)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 4. Estimated Value Sell-Out */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 p-4 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-extrabold text-slate-400 uppercase tracking-wider">
|
||||
Estimated Value Sell-Out ($)
|
||||
</span>
|
||||
<div className="p-1.5 rounded-xl bg-emerald-50 text-emerald-600 border border-emerald-200">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-2xl font-extrabold text-emerald-600 font-mono mb-0.5">
|
||||
${totalSellOutValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium">
|
||||
Calculated (Volume Sell-Out * Shelf Price)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Detailed Inventory & Sell-Out Table */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100 shadow-2xs overflow-hidden">
|
||||
<div className="p-4 border-b border-sky-100 bg-sky-50/30 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900">
|
||||
Physical Inventory and Sell-Out by Store
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">
|
||||
Relationship between dispatched Sell-In, physical inventory, and calculated Sell-Out (Units and Value).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="bg-sky-50/60 text-slate-500 font-extrabold uppercase text-[10px] tracking-wider border-b border-sky-100">
|
||||
<tr>
|
||||
<th className="px-4 py-3">Store / Customer</th>
|
||||
<th className="px-4 py-3">Zone / Channel</th>
|
||||
<th className="px-4 py-3">Sell-In (Units Sold In)</th>
|
||||
<th className="px-4 py-3">Total Physical Inv (Floor + Backroom)</th>
|
||||
<th className="px-4 py-3 text-amber-700 bg-amber-50/40">Sell-Out (Units)</th>
|
||||
<th className="px-4 py-3">Shelf Price</th>
|
||||
<th className="px-4 py-3 text-emerald-700 bg-emerald-50/30">Value Sell-Out ($)</th>
|
||||
<th className="px-4 py-3 text-right">View Report</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-sky-100/60 text-slate-700">
|
||||
{visitedItems.map(item => {
|
||||
const inv = item.response?.totalPhysicalInventory || 0;
|
||||
const price = item.response?.retailPrice || 0;
|
||||
const sellIn = item.store.unitsSoldIn || 0;
|
||||
const sellOutUnits = Math.max(0, sellIn - inv);
|
||||
const valSellOut = sellOutUnits * price;
|
||||
|
||||
return (
|
||||
<tr key={item.store.customerCode} className="hover:bg-sky-50/30 transition-colors">
|
||||
<td className="px-4 py-3 font-bold text-slate-900">{item.store.customer}</td>
|
||||
<td className="px-4 py-3 text-slate-700 font-medium">{item.store.zone} - {item.store.customerChannel}</td>
|
||||
<td className="px-4 py-3 font-bold text-sky-600 font-mono">{sellIn.toLocaleString()} units</td>
|
||||
<td className="px-4 py-3 font-extrabold text-slate-900 font-mono">
|
||||
{inv.toLocaleString()} units
|
||||
{item.hasOOS && inv > 0 && (
|
||||
<span className="block text-[10px] text-amber-600 font-bold">⚠️ In Backroom (OOS on shelf)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-black text-amber-700 bg-amber-50/30 font-mono">
|
||||
{sellOutUnits.toLocaleString()} units
|
||||
</td>
|
||||
<td className="px-4 py-3 font-bold text-slate-800 font-mono">${price.toFixed(2)}</td>
|
||||
<td className="px-4 py-3 font-bold text-emerald-600 bg-emerald-50/20 font-mono">${valSellOut.toFixed(2)}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => onSelectStore(item)}
|
||||
className="text-xs font-bold text-sky-700 hover:text-sky-900 hover:underline cursor-pointer"
|
||||
>
|
||||
View Details →
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
import React, { FormEvent, useState } from 'react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
CheckCircle2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
LoaderCircle,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
UserRound,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
AuthSession,
|
||||
RecoverySession,
|
||||
sendPasswordReset,
|
||||
signInWithPassword,
|
||||
signUpWithPassword,
|
||||
updatePasswordFromRecovery,
|
||||
getRememberedEmail,
|
||||
setRememberedEmail
|
||||
} from '../services/supabaseAuth';
|
||||
|
||||
type AuthMode = 'login' | 'register' | 'forgot';
|
||||
|
||||
interface AuthScreenProps {
|
||||
recoverySession?: RecoverySession | null;
|
||||
recoveryError?: string;
|
||||
onAuthenticated: (session: AuthSession) => void;
|
||||
onRecoveryComplete: () => void;
|
||||
}
|
||||
|
||||
function friendlyError(message: string): string {
|
||||
const normalized = message.toLowerCase();
|
||||
if (normalized.includes('invalid login credentials') || normalized.includes('invalid authentication credentials')) return 'Incorrect email or password.';
|
||||
if (normalized.includes('email not confirmed')) return 'Please confirm your email before signing in.';
|
||||
if (normalized.includes('not authorized to access')) return 'This account does not have access to this application.';
|
||||
if (normalized.includes('verify access')) return 'Access could not be verified. Please try again.';
|
||||
if (normalized.includes('user already registered') || normalized.includes('account already exists')) return 'An account already exists for this email.';
|
||||
if (normalized.includes('password should be')) return 'Use a password with at least 8 characters.';
|
||||
if (normalized.includes('rate limit') || normalized.includes('too many attempts')) return 'Too many attempts. Please wait 15 minutes and try again.';
|
||||
return message;
|
||||
}
|
||||
|
||||
export const AuthScreen: React.FC<AuthScreenProps> = ({
|
||||
recoverySession = null,
|
||||
recoveryError = '',
|
||||
onAuthenticated,
|
||||
onRecoveryComplete
|
||||
}) => {
|
||||
const recoveryMode = Boolean(recoverySession);
|
||||
const [mode, setMode] = useState<AuthMode>('login');
|
||||
const rememberedEmail = getRememberedEmail();
|
||||
const [fullName, setFullName] = useState('');
|
||||
const [email, setEmail] = useState(rememberedEmail);
|
||||
const [rememberMe, setRememberMe] = useState(Boolean(rememberedEmail));
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(recoveryError);
|
||||
const [success, setSuccess] = useState('');
|
||||
|
||||
const resetMessages = () => {
|
||||
setError('');
|
||||
setSuccess('');
|
||||
};
|
||||
|
||||
const switchMode = (nextMode: AuthMode) => {
|
||||
setMode(nextMode);
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
resetMessages();
|
||||
};
|
||||
|
||||
const handleLogin = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
resetMessages();
|
||||
setLoading(true);
|
||||
try {
|
||||
const session = await signInWithPassword(email.trim(), password, rememberMe);
|
||||
onAuthenticated(session);
|
||||
} catch (err: any) {
|
||||
setError(friendlyError(err?.message || 'Unable to sign in.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
resetMessages();
|
||||
|
||||
if (password.length < 8) {
|
||||
setError('Use a password with at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await signUpWithPassword(fullName.trim(), email.trim(), password);
|
||||
if (result.session) {
|
||||
onAuthenticated(result.session);
|
||||
} else {
|
||||
setMode('login');
|
||||
setSuccess(result.message);
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(friendlyError(err?.message || 'Unable to create the account.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleForgot = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
resetMessages();
|
||||
setLoading(true);
|
||||
try {
|
||||
const message = await sendPasswordReset(email.trim());
|
||||
setSuccess(message);
|
||||
} catch (err: any) {
|
||||
setError(friendlyError(err?.message || 'Unable to send the recovery email.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdatePassword = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
resetMessages();
|
||||
|
||||
if (!recoverySession) {
|
||||
setError('The recovery link is invalid or has expired.');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setError('Use a password with at least 8 characters.');
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await updatePasswordFromRecovery(recoverySession, password);
|
||||
setSuccess('Password updated successfully. You can now sign in.');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setTimeout(() => onRecoveryComplete(), 900);
|
||||
} catch (err: any) {
|
||||
setError(friendlyError(err?.message || 'Unable to update the password.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isRegister = mode === 'register';
|
||||
const isForgot = mode === 'forgot';
|
||||
const title = recoveryMode
|
||||
? 'Create a new password'
|
||||
: isRegister
|
||||
? 'Create your account'
|
||||
: isForgot
|
||||
? 'Recover your password'
|
||||
: 'Welcome back';
|
||||
const subtitle = recoveryMode
|
||||
? 'Enter a new secure password for your account.'
|
||||
: isRegister
|
||||
? 'Register to access the audit dashboard.'
|
||||
: isForgot
|
||||
? 'We will send a secure recovery link to your email.'
|
||||
: 'Sign in to access the audit dashboard.';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 relative overflow-hidden flex items-center justify-center p-4 sm:p-6 font-figtree">
|
||||
<div className="absolute -top-24 -left-24 w-80 h-80 rounded-full bg-sky-200/50 blur-3xl pointer-events-none" />
|
||||
<div className="absolute -bottom-28 -right-20 w-96 h-96 rounded-full bg-amber-200/45 blur-3xl pointer-events-none" />
|
||||
|
||||
<div className="relative w-full max-w-md">
|
||||
<div className="bg-white/95 backdrop-blur-xl border border-sky-100 rounded-[28px] shadow-2xl shadow-sky-900/10 overflow-hidden">
|
||||
<div className="h-1.5 bg-gradient-to-r from-sky-500 via-blue-600 to-amber-400" />
|
||||
|
||||
<div className="px-6 sm:px-8 pt-7 pb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-7">
|
||||
<div className="w-11 h-11 rounded-2xl bg-gradient-to-br from-sky-500 to-blue-600 flex items-center justify-center shadow-lg shadow-sky-500/20 border border-sky-300/40">
|
||||
<Zap className="w-5 h-5 fill-amber-300 text-amber-300" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[10px] uppercase tracking-[0.2em] font-black text-amber-700">Ice Kick</div>
|
||||
<div className="font-ibm font-extrabold text-slate-900 leading-tight">Lucozade Store Audit</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!recoveryMode && !isForgot && (
|
||||
<div className="grid grid-cols-2 p-1 bg-sky-50 border border-sky-100 rounded-xl mb-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode('login')}
|
||||
className={`py-2 rounded-lg text-xs font-extrabold transition-all cursor-pointer ${
|
||||
mode === 'login' ? 'bg-white text-sky-700 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode('register')}
|
||||
className={`py-2 rounded-lg text-xs font-extrabold transition-all cursor-pointer ${
|
||||
mode === 'register' ? 'bg-white text-sky-700 shadow-sm' : 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isForgot || recoveryMode) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => recoveryMode ? window.location.assign(import.meta.env.BASE_URL || '/') : switchMode('login')}
|
||||
className="inline-flex items-center gap-1.5 text-xs font-bold text-slate-500 hover:text-sky-700 mb-5 cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" /> Back to sign in
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="mb-6">
|
||||
<h1 className="font-ibm text-2xl font-extrabold tracking-tight text-slate-900">{title}</h1>
|
||||
<p className="text-sm text-slate-500 mt-1.5 leading-relaxed">{subtitle}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-rose-200 bg-rose-50 px-3.5 py-3 text-xs font-semibold text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="mb-4 rounded-xl border border-emerald-200 bg-emerald-50 px-3.5 py-3 text-xs font-semibold text-emerald-700 flex gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 shrink-0" />
|
||||
<span>{success}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{recoveryMode ? (
|
||||
<form onSubmit={handleUpdatePassword} className="space-y-4">
|
||||
<PasswordField
|
||||
label="New password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword(value => !value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordField
|
||||
label="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword(value => !value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<SubmitButton loading={loading} label="Update password" />
|
||||
</form>
|
||||
) : isForgot ? (
|
||||
<form onSubmit={handleForgot} className="space-y-4">
|
||||
<EmailField value={email} onChange={setEmail} />
|
||||
<SubmitButton loading={loading} label="Send recovery link" />
|
||||
</form>
|
||||
) : isRegister ? (
|
||||
<form onSubmit={handleRegister} className="space-y-4">
|
||||
<Field
|
||||
label="Name"
|
||||
value={fullName}
|
||||
onChange={setFullName}
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
icon={<UserRound className="w-4 h-4" />}
|
||||
/>
|
||||
<EmailField value={email} onChange={setEmail} />
|
||||
<PasswordField
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword(value => !value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<PasswordField
|
||||
label="Confirm password"
|
||||
value={confirmPassword}
|
||||
onChange={setConfirmPassword}
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword(value => !value)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<SubmitButton loading={loading} label="Create account" />
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<EmailField value={email} onChange={setEmail} />
|
||||
<PasswordField
|
||||
label="Password"
|
||||
value={password}
|
||||
onChange={setPassword}
|
||||
show={showPassword}
|
||||
onToggle={() => setShowPassword(value => !value)}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 -mt-1">
|
||||
<label className="inline-flex items-center gap-2 text-xs font-bold text-slate-600 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rememberMe}
|
||||
onChange={event => {
|
||||
const checked = event.target.checked;
|
||||
setRememberMe(checked);
|
||||
if (!checked) setRememberedEmail('', false);
|
||||
}}
|
||||
className="h-4 w-4 rounded border-slate-300 accent-sky-600 cursor-pointer"
|
||||
/>
|
||||
Remember me
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode('forgot')}
|
||||
className="text-xs font-bold text-sky-700 hover:text-sky-900 cursor-pointer"
|
||||
>
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
<SubmitButton loading={loading} label="Sign in" />
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-[11px] text-slate-400 mt-4">Secure access powered by encrypted authentication.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
type: string;
|
||||
autoComplete: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const Field: React.FC<FieldProps> = ({ label, value, onChange, type, autoComplete, icon }) => (
|
||||
<label className="block">
|
||||
<span className="block text-[11px] font-extrabold text-slate-600 mb-1.5">{label}</span>
|
||||
<div className="relative">
|
||||
<div className="absolute left-3.5 top-1/2 -translate-y-1/2 text-slate-400">{icon}</div>
|
||||
<input
|
||||
required
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
autoComplete={autoComplete}
|
||||
className="w-full h-11 pl-10 pr-3.5 rounded-xl bg-slate-50 border border-slate-200 text-sm text-slate-900 placeholder:text-slate-400 outline-none transition focus:bg-white focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
const EmailField: React.FC<{ value: string; onChange: (value: string) => void }> = ({ value, onChange }) => (
|
||||
<Field
|
||||
label="User (email address)"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
icon={<Mail className="w-4 h-4" />}
|
||||
/>
|
||||
);
|
||||
|
||||
interface PasswordFieldProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
show: boolean;
|
||||
onToggle: () => void;
|
||||
autoComplete: string;
|
||||
}
|
||||
|
||||
const PasswordField: React.FC<PasswordFieldProps> = ({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
show,
|
||||
onToggle,
|
||||
autoComplete
|
||||
}) => (
|
||||
<label className="block">
|
||||
<span className="block text-[11px] font-extrabold text-slate-600 mb-1.5">{label}</span>
|
||||
<div className="relative">
|
||||
<LockKeyhole className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
required
|
||||
minLength={8}
|
||||
type={show ? 'text' : 'password'}
|
||||
value={value}
|
||||
onChange={event => onChange(event.target.value)}
|
||||
autoComplete={autoComplete}
|
||||
className="w-full h-11 pl-10 pr-11 rounded-xl bg-slate-50 border border-slate-200 text-sm text-slate-900 outline-none transition focus:bg-white focus:border-sky-400 focus:ring-4 focus:ring-sky-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-label={show ? 'Hide password' : 'Show password'}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-sky-700 cursor-pointer"
|
||||
>
|
||||
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
|
||||
const SubmitButton: React.FC<{ loading: boolean; label: string }> = ({ loading, label }) => (
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full h-11 rounded-xl bg-gradient-to-r from-sky-500 to-blue-600 hover:from-sky-600 hover:to-blue-700 text-white text-sm font-extrabold shadow-lg shadow-sky-500/20 transition-all active:scale-[0.99] disabled:opacity-60 disabled:cursor-not-allowed cursor-pointer flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && <LoaderCircle className="w-4 h-4 animate-spin" />}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
@@ -0,0 +1,586 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Presentation,
|
||||
CheckCircle2,
|
||||
Filter,
|
||||
BarChart3,
|
||||
Layers,
|
||||
Database,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Eye,
|
||||
PackageCheck,
|
||||
Zap,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
Sliders,
|
||||
FileSpreadsheet
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ClientTutorialModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ClientTutorialModal: React.FC<ClientTutorialModalProps> = ({
|
||||
isOpen,
|
||||
onClose
|
||||
}) => {
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
const totalSlides = 7;
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
if (e.key === 'ArrowRight' || e.key === 'Space') {
|
||||
setCurrentSlide(prev => Math.min(prev + 1, totalSlides - 1));
|
||||
} else if (e.key === 'ArrowLeft') {
|
||||
setCurrentSlide(prev => Math.max(prev - 1, 0));
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, totalSlides, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const slides = [
|
||||
// Slide 1: Introduction / Overview
|
||||
{
|
||||
title: "Store Audit Dashboard Overview",
|
||||
subtitle: "Executive POS Monitoring Platform: Lucozade Sport Ice Kick",
|
||||
badge: "Module 1: Overview",
|
||||
icon: <Zap className="w-5 h-5 text-amber-800" />,
|
||||
content: (
|
||||
<div className="space-y-5 font-figtree">
|
||||
<div className="bg-sky-50/70 p-4 rounded-2xl border border-sky-100 flex items-start gap-3">
|
||||
<div className="p-2 bg-sky-500 text-white rounded-xl shrink-0 mt-0.5">
|
||||
<Presentation className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-bold text-slate-900 text-sm font-ibm">What is the Store Audit Dashboard?</h4>
|
||||
<p className="text-xs text-slate-600 mt-1 leading-relaxed">
|
||||
An executive, real-time management platform designed to audit point-of-sale (POS) commercial execution for <strong>Lucozade Sport Ice Kick</strong>, tracking stock availability (OOS), shelf share, POP promotional material, and estimated Sell-Out sales.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5">
|
||||
<div className="bg-white p-4 rounded-2xl border border-slate-200/80 shadow-2xs">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="p-1.5 rounded-lg bg-emerald-50 text-emerald-700">
|
||||
<Database className="w-4 h-4" />
|
||||
</span>
|
||||
<h5 className="font-bold text-slate-800 text-xs font-ibm">1. Master Store List</h5>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||||
Contains the official universe of registered stores, customer coding, channel type, geographic zone, and warehouse shipments (<strong>Sell-In</strong>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white p-4 rounded-2xl border border-slate-200/80 shadow-2xs">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="p-1.5 rounded-lg bg-sky-50 text-sky-700">
|
||||
<FileSpreadsheet className="w-4 h-4" />
|
||||
</span>
|
||||
<h5 className="font-bold text-slate-800 text-xs font-ibm">2. Field Audit Responses</h5>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||||
Live field data captured directly by auditors via Google Form: retail shelf prices, OOS status, facings, POP placement, and shelf photo proof.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50/70 border border-amber-200/80 p-3.5 rounded-2xl flex items-center gap-3">
|
||||
<div className="p-2 bg-amber-100 text-amber-800 rounded-xl shrink-0">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs font-bold text-amber-950 font-ibm">Automated Real-Time Synchronization</div>
|
||||
<p className="text-2xs text-amber-800 mt-0.5">
|
||||
Whenever a field auditor submits a store audit, data automatically syncs to this live dashboard without requiring manual consolidation or uploads.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Slide 2: Key Metrics
|
||||
{
|
||||
title: "Key Performance Indicators (KPIs)",
|
||||
subtitle: "Executive Metrics & Commercial Performance Tracker",
|
||||
badge: "Module 2: Key Metrics",
|
||||
icon: <BarChart3 className="w-5 h-5 text-sky-600" />,
|
||||
content: (
|
||||
<div className="space-y-4 font-figtree">
|
||||
<p className="text-xs text-slate-600 leading-relaxed">
|
||||
The top summary panel consolidates 11 executive metrics updated dynamically based on active filters:
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
|
||||
<div className="p-3 bg-sky-50/60 rounded-xl border border-sky-100">
|
||||
<div className="text-[10px] font-bold text-sky-800 uppercase font-ibm">Store Audit Coverage</div>
|
||||
<div className="text-lg font-bold text-sky-700 font-geist mt-0.5">% Coverage</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Percentage of visited stores against total assigned target.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-rose-50/60 rounded-xl border border-rose-100">
|
||||
<div className="text-[10px] font-bold text-rose-800 uppercase font-ibm">Out Of Stock (OOS)</div>
|
||||
<div className="text-lg font-bold text-rose-600 font-geist mt-0.5">% OOS Rate</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Stores where Lucozade Ice Kick is out of stock on the shelf.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-emerald-50/60 rounded-xl border border-emerald-100">
|
||||
<div className="text-[10px] font-bold text-emerald-800 uppercase font-ibm">% Add. Exhibition</div>
|
||||
<div className="text-lg font-bold text-emerald-700 font-geist mt-0.5">Extra Display %</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Presence across secondary displays and gondola end caps.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-50 rounded-xl border border-slate-200/80">
|
||||
<div className="text-[10px] font-bold text-slate-700 uppercase font-ibm">Lucozade Share</div>
|
||||
<div className="text-lg font-bold text-slate-800 font-geist mt-0.5">Facing Share %</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Ice Kick facings share relative to total Lucozade brand.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-50/60 rounded-xl border border-amber-100">
|
||||
<div className="text-[10px] font-bold text-amber-900 uppercase font-ibm">Volume Sell-Out</div>
|
||||
<div className="text-lg font-bold text-amber-700 font-geist mt-0.5">Units Sold</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Rotation calculation: <code>Sell-In - Physical Inventory</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-emerald-50/60 rounded-xl border border-emerald-100">
|
||||
<div className="text-[10px] font-bold text-emerald-900 uppercase font-ibm">Value Sell-Out</div>
|
||||
<div className="text-lg font-bold text-emerald-700 font-geist mt-0.5">Sales Value ($)</div>
|
||||
<p className="text-[10px] text-slate-500 mt-1">
|
||||
Monetary valuation: <code>Sell-Out Units * Shelf Price</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Slide 3: Smart Filters
|
||||
{
|
||||
title: "Smart Filtering System",
|
||||
subtitle: "Dynamic Segmentation by Region, Channel, & Audit Status",
|
||||
badge: "Module 3: Filters",
|
||||
icon: <Filter className="w-5 h-5 text-sky-600" />,
|
||||
content: (
|
||||
<div className="space-y-4 font-figtree">
|
||||
<div className="bg-slate-50 p-3.5 rounded-xl border border-slate-200/80 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="w-4 h-4 text-sky-600" />
|
||||
<span className="text-xs font-bold text-slate-800 font-ibm">Multi-Criteria Segmentation</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold text-sky-700 bg-sky-100 px-2 py-0.5 rounded-md">
|
||||
Combinable Filters
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-xl border border-slate-100 shadow-2xs">
|
||||
<div className="w-6 h-6 rounded-lg bg-sky-100 text-sky-800 flex items-center justify-center font-bold text-xs shrink-0 font-geist">
|
||||
1
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-900 font-ibm">Geographic Region (Zone)</span>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
Filter by country zones (e.g. West, South, Central, East, North) to measure regional performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-xl border border-slate-100 shadow-2xs">
|
||||
<div className="w-6 h-6 rounded-lg bg-sky-100 text-sky-800 flex items-center justify-center font-bold text-xs shrink-0 font-geist">
|
||||
2
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-900 font-ibm">Sales Channel (Channel)</span>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
Segmentation by customer type: Supermarkets, Service Stations (Petrol/Convenience), Wholesale, etc.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-xl border border-slate-100 shadow-2xs">
|
||||
<div className="w-6 h-6 rounded-lg bg-sky-100 text-sky-800 flex items-center justify-center font-bold text-xs shrink-0 font-geist">
|
||||
3
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-900 font-ibm">Audit Status & OOS Availability</span>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
Compare <strong>Visited vs Pending</strong> stores, and immediately pinpoint PDVs with active <strong>Out of Stock</strong> alerts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 bg-white rounded-xl border border-slate-100 shadow-2xs">
|
||||
<div className="w-6 h-6 rounded-lg bg-sky-100 text-sky-800 flex items-center justify-center font-bold text-xs shrink-0 font-geist">
|
||||
4
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-slate-900 font-ibm">Direct Search by Store Code or Name</span>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
Use the instant search bar to find any specific customer code or store name.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Slide 4: Steps 1 & 2
|
||||
{
|
||||
title: "Audit Methodology (Steps 1 & 2)",
|
||||
subtitle: "Presence Verification, OOS, Location Mapping & POP Material",
|
||||
badge: "Module 4: Steps 1 & 2",
|
||||
icon: <Eye className="w-5 h-5 text-amber-800" />,
|
||||
content: (
|
||||
<div className="space-y-4 font-figtree">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Step 1 */}
|
||||
<div className="p-4 bg-white rounded-2xl border border-slate-200/80 shadow-2xs space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-900 text-[10px] font-bold px-2 py-0.5 rounded-full font-figtree">
|
||||
STEP 1
|
||||
</span>
|
||||
<h5 className="font-bold text-slate-900 text-xs font-ibm">Presence & OOS Availability</h5>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-600 leading-relaxed">
|
||||
First in-store check. Confirms whether Lucozade Sport Ice Kick is available for purchase.
|
||||
</p>
|
||||
<ul className="text-2xs text-slate-500 space-y-1 list-disc pl-4">
|
||||
<li>Immediate detection of stockouts (OOS).</li>
|
||||
<li>Identification of non-availability causes.</li>
|
||||
<li>Mapping of effective numerical distribution.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="p-4 bg-white rounded-2xl border border-slate-200/80 shadow-2xs space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-900 text-[10px] font-bold px-2 py-0.5 rounded-full font-figtree">
|
||||
STEP 2
|
||||
</span>
|
||||
<h5 className="font-bold text-slate-900 text-xs font-ibm">Placement & POP Material</h5>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-600 leading-relaxed">
|
||||
Evaluation of product visibility and promotional support at the point of sale.
|
||||
</p>
|
||||
<ul className="text-2xs text-slate-500 space-y-1 list-disc pl-4">
|
||||
<li>Placement: Main Shelf, Chiller, Secondary Display, Gondola End.</li>
|
||||
<li>POP Material check: Wobblers, Shelf Talkers, Posters, Branded Coolers.</li>
|
||||
<li>Visual merchandising compliance audit.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-sky-50 rounded-xl border border-sky-100 text-2xs text-sky-900 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-sky-600 shrink-0" />
|
||||
<span>Dedicated tabs in the top navigation bar allow you to inspect each step individually.</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Slide 5: Steps 3 & 4
|
||||
{
|
||||
title: "Audit Methodology (Steps 3 & 4)",
|
||||
subtitle: "Main Shelf Measurement, Facings, Inventory & Sell-Out",
|
||||
badge: "Module 5: Steps 3 & 4",
|
||||
icon: <PackageCheck className="w-5 h-5 text-emerald-600" />,
|
||||
content: (
|
||||
<div className="space-y-4 font-figtree">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Step 3 */}
|
||||
<div className="p-4 bg-white rounded-2xl border border-slate-200/80 shadow-2xs space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-900 text-[10px] font-bold px-2 py-0.5 rounded-full font-figtree">
|
||||
STEP 3
|
||||
</span>
|
||||
<h5 className="font-bold text-slate-900 text-xs font-ibm">Main Shelf, Facings & Price</h5>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-600 leading-relaxed">
|
||||
Quantification of physical shelf space and verification of retail consumer price.
|
||||
</p>
|
||||
<ul className="text-2xs text-slate-500 space-y-1 list-disc pl-4">
|
||||
<li>Count of Ice Kick facings vs Lucozade Brand vs Category.</li>
|
||||
<li>Real-time Share of Shelf (%) calculation.</li>
|
||||
<li>SKU substitution check when Ice Kick is missing.</li>
|
||||
<li>Shelf retail price ($) recording.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Step 4 */}
|
||||
<div className="p-4 bg-white rounded-2xl border border-slate-200/80 shadow-2xs space-y-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-900 text-[10px] font-bold px-2 py-0.5 rounded-full font-figtree">
|
||||
STEP 4
|
||||
</span>
|
||||
<h5 className="font-bold text-slate-900 text-xs font-ibm">Physical Inventory & Sell-Out</h5>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-600 leading-relaxed">
|
||||
Total store unit count and inferred Sell-Out volume/value.
|
||||
</p>
|
||||
<ul className="text-2xs text-slate-500 space-y-1 list-disc pl-4">
|
||||
<li>Sum of sales floor inventory + backroom warehouse stock.</li>
|
||||
<li>Sell-Out Units Formula: <code>Sell-In - Physical Inventory</code>.</li>
|
||||
<li>Sell-Out Value ($) Formula: <code>Units * Shelf Price</code>.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 bg-emerald-50/80 border border-emerald-200/80 rounded-2xl flex items-center justify-between">
|
||||
<div className="text-2xs text-emerald-950 font-medium">
|
||||
<strong>Precise Inventory Control:</strong> Enables real sales velocity measurement and replenishment planning before stockouts occur.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Slide 6: Master Directory & CSV Export
|
||||
{
|
||||
title: "Consolidated Directory & CSV Export",
|
||||
subtitle: "Complete Master Table & Individual Store Audit Reports",
|
||||
badge: "Module 6: Data & Export",
|
||||
icon: <Download className="w-5 h-5 text-amber-800" />,
|
||||
content: (
|
||||
<div className="space-y-4 font-figtree">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
||||
<div className="p-3.5 bg-white rounded-xl border border-slate-200/80 shadow-2xs space-y-1.5">
|
||||
<div className="p-1.5 bg-sky-50 text-sky-700 rounded-lg w-fit">
|
||||
<Layers className="w-4 h-4" />
|
||||
</div>
|
||||
<h5 className="font-bold text-slate-900 text-xs font-ibm">14 Master Columns</h5>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||||
Comprehensive comparative table detailing POS, Zone, Channel, Status, OOS, Placement, POP, Price, Facings, Inventory, Sell-In, and Sell-Out.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 bg-white rounded-xl border border-slate-200/80 shadow-2xs space-y-1.5">
|
||||
<div className="p-1.5 bg-amber-50 text-amber-800 rounded-lg w-fit">
|
||||
<Eye className="w-4 h-4" />
|
||||
</div>
|
||||
<h5 className="font-bold text-slate-900 text-xs font-ibm">Individual Store Report (View)</h5>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||||
Click <strong>Report</strong> to open the complete store dossier with form photo evidence and step-by-step breakdown.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 bg-white rounded-xl border border-slate-200/80 shadow-2xs space-y-1.5">
|
||||
<div className="p-1.5 bg-emerald-50 text-emerald-700 rounded-lg w-fit">
|
||||
<Download className="w-4 h-4" />
|
||||
</div>
|
||||
<h5 className="font-bold text-slate-900 text-xs font-ibm">Clean CSV Export</h5>
|
||||
<p className="text-2xs text-slate-500 leading-relaxed">
|
||||
Download optimized Excel/Google Sheets reports with UTF-8 BOM encoding to prevent misaligned rows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-slate-100/80 rounded-xl border border-slate-200 text-2xs text-slate-700 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600 shrink-0" />
|
||||
<span>Clicking on table headers (e.g., Sell-Out, Price, Zone) instantly sorts the store directory ascending or descending.</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
|
||||
// Slide 7: Client Workflow
|
||||
{
|
||||
title: "Recommended Client Workflow",
|
||||
subtitle: "Best Practices for Daily / Weekly Management",
|
||||
badge: "Module 7: Client Use Case",
|
||||
icon: <Sparkles className="w-5 h-5 text-amber-800" />,
|
||||
content: (
|
||||
<div className="space-y-4 font-figtree">
|
||||
<p className="text-xs text-slate-600">
|
||||
Recommended steps to maximize commercial value from the audit dashboard:
|
||||
</p>
|
||||
|
||||
<div className="relative border-l-2 border-sky-200 ml-3 pl-4 space-y-3.5">
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[23px] top-0 w-3 h-3 bg-sky-500 rounded-full ring-4 ring-white" />
|
||||
<h5 className="text-xs font-bold text-slate-900 font-ibm">1. Initial Coverage Verification</h5>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
Review the <strong>Store Audit Coverage</strong> metric to track field team progress along planned routes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[23px] top-0 w-3 h-3 bg-rose-500 rounded-full ring-4 ring-white" />
|
||||
<h5 className="text-xs font-bold text-slate-900 font-ibm">2. Priority Out-of-Stock (OOS) Resolution</h5>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
Filter by <strong>OOS Status = Out of Stock</strong> to trigger emergency replenishments for key stores.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[23px] top-0 w-3 h-3 bg-amber-500 rounded-full ring-4 ring-white" />
|
||||
<h5 className="text-xs font-bold text-slate-900 font-ibm">3. Merchandising & Display Optimization</h5>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
Analyze <strong>Step 2</strong> and <strong>Step 3</strong> tabs to negotiate extra shelf space or POP placement in low-share stores.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -left-[23px] top-0 w-3 h-3 bg-emerald-500 rounded-full ring-4 ring-white" />
|
||||
<h5 className="text-xs font-bold text-slate-900 font-ibm">4. Management Reporting & Export</h5>
|
||||
<p className="text-2xs text-slate-500 mt-0.5">
|
||||
Export the consolidated CSV report for weekly performance reviews and client presentations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 text-center">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 bg-amber-100 hover:bg-amber-200/80 text-amber-950 font-bold border border-amber-200/80 rounded-xl text-xs transition-all shadow-2xs active:scale-95 cursor-pointer font-figtree"
|
||||
>
|
||||
<span>Got it! Start using the dashboard</span>
|
||||
<ArrowRight className="w-4 h-4 text-amber-900" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
|
||||
<div className="bg-white rounded-3xl border border-sky-100 max-w-3xl w-full shadow-2xl overflow-hidden flex flex-col max-h-[90vh] animate-in fade-in zoom-in-95 duration-150">
|
||||
|
||||
{/* Slide Deck Header Bar */}
|
||||
<div className="bg-sky-50/80 px-6 py-4 border-b border-sky-100 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-xl bg-amber-100 text-amber-800 border border-amber-200/80 shrink-0">
|
||||
<Presentation className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-sky-100 text-sky-800 text-[10px] font-bold px-2 py-0.5 rounded-full font-figtree">
|
||||
{slides[currentSlide].badge}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-400 font-geist">
|
||||
Slide {currentSlide + 1} of {totalSlides}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="text-sm sm:text-base font-bold text-slate-900 tracking-tight font-ibm">
|
||||
Client Dashboard User Guide
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-white rounded-xl transition-colors cursor-pointer"
|
||||
title="Close presentation"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Slide Progress Indicator Bar */}
|
||||
<div className="w-full bg-slate-100 h-1.5 flex">
|
||||
{slides.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
onClick={() => setCurrentSlide(idx)}
|
||||
className={`h-full flex-1 transition-all cursor-pointer ${
|
||||
idx === currentSlide
|
||||
? 'bg-amber-300'
|
||||
: idx < currentSlide
|
||||
? 'bg-sky-400'
|
||||
: 'bg-slate-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Slide Content Area */}
|
||||
<div className="p-6 sm:p-8 flex-1 overflow-y-auto bg-white flex flex-col justify-between">
|
||||
<div>
|
||||
{/* Slide Title Header */}
|
||||
<div className="mb-6 border-b border-slate-100 pb-4 flex items-start gap-3">
|
||||
<div className="p-2 bg-sky-50 rounded-xl text-sky-700 shrink-0 mt-1">
|
||||
{slides[currentSlide].icon}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg sm:text-xl font-bold text-slate-900 font-ibm">
|
||||
{slides[currentSlide].title}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-figtree">
|
||||
{slides[currentSlide].subtitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide Specific Content */}
|
||||
{slides[currentSlide].content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Slide Footer Controls Bar */}
|
||||
<div className="bg-slate-50/80 px-6 py-4 border-t border-slate-200/80 flex items-center justify-between font-figtree">
|
||||
<button
|
||||
onClick={() => setCurrentSlide(prev => Math.max(prev - 1, 0))}
|
||||
disabled={currentSlide === 0}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-bold text-slate-700 bg-white border border-slate-200 rounded-xl hover:bg-slate-100 transition-all shadow-2xs disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span>Previous</span>
|
||||
</button>
|
||||
|
||||
{/* Dots Indicator */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
{slides.map((_, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setCurrentSlide(idx)}
|
||||
className={`w-2.5 h-2.5 rounded-full transition-all cursor-pointer ${
|
||||
idx === currentSlide ? 'bg-amber-300 w-6' : 'bg-slate-300 hover:bg-slate-400'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (currentSlide < totalSlides - 1) {
|
||||
setCurrentSlide(prev => prev + 1);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 text-xs font-bold text-amber-950 bg-amber-100 hover:bg-amber-200/80 border border-amber-200/80 rounded-xl transition-all shadow-2xs cursor-pointer"
|
||||
>
|
||||
<span>{currentSlide === totalSlides - 1 ? 'Finish Tutorial' : 'Next'}</span>
|
||||
<ChevronRight className="w-4 h-4 text-amber-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,358 @@
|
||||
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { FilterState, Store } from '../types';
|
||||
import { WeekOption } from '../utils/dateUtils';
|
||||
import { Search, Filter, RotateCcw, Store as StoreIcon, ChevronDown, Check, X, Building2, Calendar } from 'lucide-react';
|
||||
|
||||
interface FilterBarProps {
|
||||
filters: FilterState;
|
||||
stores: Store[];
|
||||
weekOptions: WeekOption[];
|
||||
onChange: (newFilters: FilterState) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
interface SearchableStoreSelectProps {
|
||||
stores: Store[];
|
||||
selectedCustomer: string;
|
||||
onSelectCustomer: (customerName: string) => void;
|
||||
}
|
||||
|
||||
const SearchableStoreSelect: React.FC<SearchableStoreSelectProps> = ({
|
||||
stores,
|
||||
selectedCustomer,
|
||||
onSelectCustomer
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e: MouseEvent) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
// Deduplicate stores strictly by Customer Name so each store name appears ONLY ONCE
|
||||
const uniqueStores = useMemo(() => {
|
||||
const map = new Map<string, Store>();
|
||||
stores.forEach(s => {
|
||||
if (!s.customer || !s.customer.trim()) return;
|
||||
const key = s.customer.trim().toLowerCase();
|
||||
if (!map.has(key)) {
|
||||
map.set(key, s);
|
||||
}
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) => a.customer.localeCompare(b.customer));
|
||||
}, [stores]);
|
||||
|
||||
const filteredStores = useMemo(() => {
|
||||
if (!searchTerm.trim()) return uniqueStores;
|
||||
const q = searchTerm.toLowerCase().trim();
|
||||
return uniqueStores.filter(s =>
|
||||
s.customer.toLowerCase().includes(q) ||
|
||||
s.customerCode.toLowerCase().includes(q) ||
|
||||
s.zone.toLowerCase().includes(q) ||
|
||||
s.address.toLowerCase().includes(q)
|
||||
);
|
||||
}, [uniqueStores, searchTerm]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1 flex items-center justify-between font-figtree">
|
||||
<span>Store</span>
|
||||
{selectedCustomer !== 'all' && (
|
||||
<span className="text-amber-600 font-extrabold text-[10px]">1 store selected</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{/* Trigger Button */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full px-3 py-1.5 text-xs bg-sky-50/50 border ${
|
||||
selectedCustomer !== 'all'
|
||||
? 'border-amber-400 bg-amber-50/40 text-slate-900 shadow-2xs'
|
||||
: 'border-sky-100 text-slate-700 hover:border-sky-300'
|
||||
} rounded-xl text-left flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-sky-500 font-medium transition-all cursor-pointer`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate pr-2">
|
||||
<StoreIcon className={`w-3.5 h-3.5 shrink-0 ${selectedCustomer !== 'all' ? 'text-amber-600' : 'text-sky-500'}`} />
|
||||
<span className={`truncate ${selectedCustomer !== 'all' ? 'font-extrabold text-slate-900' : 'font-semibold text-slate-700'}`}>
|
||||
{selectedCustomer === 'all'
|
||||
? `All Stores (${uniqueStores.length} total)`
|
||||
: selectedCustomer}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{selectedCustomer !== 'all' && (
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectCustomer('all');
|
||||
}}
|
||||
className="p-0.5 hover:bg-amber-200 text-slate-600 hover:text-slate-950 rounded-full cursor-pointer transition-colors"
|
||||
title="Clear store selection"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
<ChevronDown className={`w-3.5 h-3.5 text-slate-400 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`} />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className="absolute left-0 right-0 top-full mt-1 z-30 bg-white border border-sky-100 rounded-2xl shadow-xl p-2.5 min-w-[300px]">
|
||||
{/* Search Input inside Dropdown */}
|
||||
<div className="relative mb-2">
|
||||
<Search className="w-3.5 h-3.5 text-sky-500 absolute left-2.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
placeholder="Search store name, code, zone..."
|
||||
autoFocus
|
||||
className="w-full pl-8 pr-7 py-1.5 text-xs bg-sky-50/60 border border-sky-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 text-slate-900 font-bold placeholder-slate-400"
|
||||
/>
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchTerm('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-700 p-0.5"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List Options */}
|
||||
<div className="max-h-60 overflow-y-auto space-y-0.5 pr-1">
|
||||
{/* Option: All Stores */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectCustomer('all');
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-xl text-xs font-bold flex items-center justify-between transition-colors cursor-pointer ${
|
||||
selectedCustomer === 'all'
|
||||
? 'bg-amber-100 text-amber-950 font-black'
|
||||
: 'text-slate-700 hover:bg-sky-50'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Building2 className="w-3.5 h-3.5 text-sky-600" />
|
||||
<span>All Stores ({uniqueStores.length})</span>
|
||||
</span>
|
||||
{selectedCustomer === 'all' && <Check className="w-3.5 h-3.5 text-amber-700 shrink-0" />}
|
||||
</button>
|
||||
|
||||
<div className="h-px bg-sky-100 my-1" />
|
||||
|
||||
{/* Filtered Stores */}
|
||||
{filteredStores.length === 0 ? (
|
||||
<div className="p-3 text-center text-xs text-slate-400 font-medium">
|
||||
No stores match "{searchTerm}"
|
||||
</div>
|
||||
) : (
|
||||
filteredStores.map(store => {
|
||||
const isSelected = selectedCustomer === store.customer;
|
||||
return (
|
||||
<button
|
||||
key={store.customer}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSelectCustomer(store.customer);
|
||||
setIsOpen(false);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 rounded-xl text-xs transition-colors flex items-center justify-between gap-2 cursor-pointer ${
|
||||
isSelected
|
||||
? 'bg-amber-400 text-slate-950 font-black shadow-2xs'
|
||||
: 'hover:bg-sky-50 text-slate-800'
|
||||
}`}
|
||||
>
|
||||
<div className="truncate min-w-0">
|
||||
<div className="font-extrabold truncate">{store.customer}</div>
|
||||
<div className={`text-[10px] font-medium flex items-center gap-1.5 ${isSelected ? 'text-slate-900' : 'text-slate-400'}`}>
|
||||
<span>{store.customerCode}</span>
|
||||
<span>•</span>
|
||||
<span>{store.zone}</span>
|
||||
</div>
|
||||
</div>
|
||||
{isSelected && <Check className="w-4 h-4 text-slate-950 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilterBar: React.FC<FilterBarProps> = ({
|
||||
filters,
|
||||
stores,
|
||||
weekOptions,
|
||||
onChange,
|
||||
onReset
|
||||
}) => {
|
||||
// Extract unique options
|
||||
const zones = Array.from(new Set(stores.map(s => s.zone))).filter(Boolean).sort();
|
||||
const channels = Array.from(new Set(stores.map(s => s.customerChannel))).filter(Boolean).sort();
|
||||
|
||||
const handleFieldChange = (field: keyof FilterState, value: string) => {
|
||||
onChange({
|
||||
...filters,
|
||||
[field]: value
|
||||
});
|
||||
};
|
||||
|
||||
const isFiltered =
|
||||
filters.zone !== 'all' ||
|
||||
filters.channel !== 'all' ||
|
||||
filters.customer !== 'all' ||
|
||||
filters.status !== 'all' ||
|
||||
filters.oosStatus !== 'all' ||
|
||||
(filters.selectedWeek && filters.selectedWeek !== 'all') ||
|
||||
filters.search.trim() !== '';
|
||||
|
||||
return (
|
||||
<div className="sticky top-[58px] sm:top-[65px] z-25 bg-white/95 backdrop-blur-md rounded-2xl border border-sky-100/90 p-3.5 sm:p-4 mb-6 shadow-md transition-all">
|
||||
<div className="flex items-center justify-between mb-3 pb-2.5 border-b border-sky-100/60">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-sky-50 text-sky-600">
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
<h2 className="text-xs font-bold text-slate-800 uppercase tracking-wider font-ibm">
|
||||
Audit Dashboard Filters
|
||||
</h2>
|
||||
</div>
|
||||
{isFiltered && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="inline-flex items-center gap-1 text-xs font-bold text-sky-700 hover:text-sky-800 bg-sky-50 hover:bg-sky-100 px-3 py-1 rounded-lg transition-colors cursor-pointer font-figtree"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reset Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-8 gap-3">
|
||||
|
||||
{/* Searchable Store Select Dropdown */}
|
||||
<div className="lg:col-span-2">
|
||||
<SearchableStoreSelect
|
||||
stores={stores}
|
||||
selectedCustomer={filters.customer}
|
||||
onSelectCustomer={(custName) => handleFieldChange('customer', custName)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Week / Period Filter */}
|
||||
<div className="lg:col-span-2">
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1 flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3 text-sky-500" />
|
||||
<span>Audit Week (Mon - Sun)</span>
|
||||
</label>
|
||||
<select
|
||||
value={filters.selectedWeek || 'all'}
|
||||
onChange={e => handleFieldChange('selectedWeek', e.target.value)}
|
||||
className={`w-full px-2.5 py-1.5 text-xs border rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 transition-all font-bold cursor-pointer ${
|
||||
filters.selectedWeek && filters.selectedWeek !== 'all'
|
||||
? 'bg-amber-50/80 border-amber-300 text-amber-950'
|
||||
: 'bg-sky-50/40 border-sky-100 text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<option value="all">All Weeks (Latest Visit per POS)</option>
|
||||
{weekOptions.map(w => (
|
||||
<option key={w.key} value={w.key}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Freeform Search (Store Code) */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
|
||||
Store Code
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Search className="w-3.5 h-3.5 text-sky-500 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
value={filters.search}
|
||||
onChange={e => handleFieldChange('search', e.target.value)}
|
||||
placeholder="e.g. CUST-1001"
|
||||
className="w-full pl-9 pr-3 py-1.5 text-xs bg-sky-50/40 border border-sky-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 focus:bg-white transition-all text-slate-900 placeholder-slate-400 font-medium"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zone */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
|
||||
Geographic Zone
|
||||
</label>
|
||||
<select
|
||||
value={filters.zone}
|
||||
onChange={e => handleFieldChange('zone', e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-sky-50/40 border border-sky-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 focus:bg-white transition-all text-slate-900 font-medium cursor-pointer"
|
||||
>
|
||||
<option value="all">All Zones</option>
|
||||
{zones.map(z => (
|
||||
<option key={z} value={z}>{z}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Channel */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
|
||||
Sales Channel
|
||||
</label>
|
||||
<select
|
||||
value={filters.channel}
|
||||
onChange={e => handleFieldChange('channel', e.target.value)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-sky-50/40 border border-sky-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 focus:bg-white transition-all text-slate-900 font-medium cursor-pointer"
|
||||
>
|
||||
<option value="all">All Channels</option>
|
||||
{channels.map(c => (
|
||||
<option key={c} value={c}>{c}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Audit Status */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1">
|
||||
Audit Status
|
||||
</label>
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={e => handleFieldChange('status', e.target.value as any)}
|
||||
className="w-full px-2.5 py-1.5 text-xs bg-sky-50/40 border border-sky-100 rounded-xl focus:outline-none focus:ring-2 focus:ring-sky-500 focus:bg-white transition-all text-slate-900 font-medium cursor-pointer"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="Visited">Visited (Audited)</option>
|
||||
<option value="Pending">Pending Audit</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import React from 'react';
|
||||
import { LogOut, Presentation, RefreshCw, UserRound, Zap } from 'lucide-react';
|
||||
|
||||
interface HeaderProps {
|
||||
lastSynced: string;
|
||||
isSyncing: boolean;
|
||||
onRefresh: () => void;
|
||||
onOpenTutorial: () => void;
|
||||
userEmail: string;
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
export const Header: React.FC<HeaderProps> = ({
|
||||
lastSynced,
|
||||
isSyncing,
|
||||
onRefresh,
|
||||
onOpenTutorial,
|
||||
userEmail,
|
||||
onLogout
|
||||
}) => {
|
||||
return (
|
||||
<header className="bg-white/90 backdrop-blur-md border-b border-sky-100 sticky top-0 z-30 shadow-xs">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between py-3.5 gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-sky-500 to-blue-600 flex items-center justify-center text-amber-300 font-black text-xl shadow-md shrink-0 border border-sky-300/40">
|
||||
<Zap className="w-5 h-5 fill-amber-300 text-amber-300" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="bg-amber-100 text-amber-900 border border-amber-200/80 font-bold text-[10px] uppercase tracking-wider px-2.5 py-0.5 rounded-full shadow-2xs font-figtree">
|
||||
ICE KICK
|
||||
</span>
|
||||
<h1 className="text-base sm:text-lg font-bold text-slate-900 tracking-tight font-ibm">
|
||||
Lucozade Sport <span className="text-sky-600">Store Audit</span>
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-2xs text-slate-500 font-medium font-figtree">
|
||||
POS Audit Dashboard, OOS Availability & Inventory Tracker
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3 w-full sm:w-auto justify-between sm:justify-end">
|
||||
<button
|
||||
onClick={onOpenTutorial}
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 text-xs font-bold text-sky-800 bg-sky-50 hover:bg-sky-100 border border-sky-200/80 rounded-xl transition-all shadow-2xs active:scale-95 cursor-pointer font-figtree"
|
||||
title="Open Client PPT Tutorial Guide"
|
||||
>
|
||||
<Presentation className="w-3.5 h-3.5 text-sky-600" />
|
||||
<span className="hidden md:inline">Client PPT Guide</span>
|
||||
<span className="md:hidden">Guide</span>
|
||||
</button>
|
||||
|
||||
<div className="hidden lg:block text-right">
|
||||
<span className="block text-[10px] text-slate-400 font-semibold uppercase tracking-wider font-figtree">
|
||||
Last Sync
|
||||
</span>
|
||||
<span className="block text-xs font-bold text-sky-700 font-geist">
|
||||
{lastSynced || 'Just Now'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={isSyncing}
|
||||
title="Refresh data"
|
||||
className="inline-flex items-center gap-2 px-3.5 py-2 text-xs font-bold text-amber-950 bg-amber-100 hover:bg-amber-200/80 border border-amber-200/80 rounded-xl transition-all shadow-2xs active:scale-95 disabled:opacity-50 cursor-pointer font-figtree"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 text-amber-800 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden md:inline">{isSyncing ? 'Refreshing...' : 'Refresh'}</span>
|
||||
</button>
|
||||
|
||||
<div className="hidden xl:flex items-center gap-2 max-w-48 px-3 py-2 rounded-xl border border-slate-200 bg-slate-50 text-slate-600" title={userEmail}>
|
||||
<UserRound className="w-3.5 h-3.5 shrink-0 text-sky-600" />
|
||||
<span className="text-[11px] font-bold truncate">{userEmail}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLogout}
|
||||
title="Sign out"
|
||||
aria-label="Sign out"
|
||||
className="inline-flex items-center justify-center gap-2 h-9 px-3 text-xs font-extrabold text-slate-600 bg-white hover:text-rose-700 hover:bg-rose-50 border border-slate-200 hover:border-rose-200 rounded-xl transition-all cursor-pointer"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,250 @@
|
||||
import React from 'react';
|
||||
import { DashboardKPIs } from '../types';
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
PackageCheck,
|
||||
Tag,
|
||||
Percent,
|
||||
Layers,
|
||||
Sparkles,
|
||||
ShoppingBag,
|
||||
DollarSign,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface KPICardsProps {
|
||||
kpis: DashboardKPIs;
|
||||
}
|
||||
|
||||
export const KPICards: React.FC<KPICardsProps> = ({ kpis }) => {
|
||||
return (
|
||||
<div className="mb-6 font-figtree">
|
||||
{/* Primary KPI Cards Grid - Exactly 2 rows on large screens (5 columns per row) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
|
||||
{/* 1. Unified Store Coverage Card (Spans 2 columns on lg screens) */}
|
||||
<div className="lg:col-span-2 bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Store Audit Coverage
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-sky-50 text-sky-600 shrink-0 flex items-center gap-1">
|
||||
<Building2 className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 items-center">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-2xl font-bold text-sky-600 tracking-tight font-geist">
|
||||
{kpis.complianceRate.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs font-bold text-slate-400 font-figtree">Coverage</span>
|
||||
</div>
|
||||
<div className="w-full bg-sky-50 rounded-full h-2 mt-1.5 overflow-hidden">
|
||||
<div
|
||||
className="bg-sky-500 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${Math.min(kpis.complianceRate, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-around bg-sky-50/50 rounded-xl p-2 border border-sky-100/60 font-figtree">
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400">Total</div>
|
||||
<div className="text-lg font-bold text-slate-800 font-geist">{kpis.totalStores}</div>
|
||||
</div>
|
||||
<div className="h-6 w-px bg-sky-200/60" />
|
||||
<div className="text-center">
|
||||
<div className="text-[10px] uppercase font-bold text-slate-400">Visited</div>
|
||||
<div className="text-lg font-bold text-emerald-600 font-geist">{kpis.visitedStores}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Out Of Stock (OOS) Rate */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Out Of Stock (OOS)
|
||||
</span>
|
||||
<div className={`p-1.5 rounded-lg shrink-0 ${
|
||||
kpis.oosStores > 0 ? 'bg-rose-50 text-rose-600' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className={`text-2xl font-bold tracking-tight font-geist ${
|
||||
kpis.oosStores > 0 ? 'text-rose-600' : 'text-emerald-600'
|
||||
}`}>
|
||||
{kpis.oosRate.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs font-bold text-rose-500 font-figtree">
|
||||
({kpis.oosStores}/{kpis.visitedStores})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-figtree">
|
||||
In Stock: <span className="font-bold text-emerald-600 font-geist">{kpis.availableStores}/{kpis.visitedStores} ({kpis.numericDistribution.toFixed(1)}%)</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. % Additional Exhibition (Secondary Display) */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
% Add. Exhibition (Secondary)
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-emerald-50 text-emerald-600 shrink-0">
|
||||
<Layers className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-bold text-emerald-600 tracking-tight font-geist">
|
||||
{kpis.secondaryDisplayRate.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs font-bold text-emerald-600 font-figtree">
|
||||
({kpis.secondaryDisplayStores}/{kpis.visitedStores})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-figtree">
|
||||
Stores with Secondary Display
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. % Additional Exhibition (Gondola End) */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
% Add. Exhibition (Gondola)
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-sky-50 text-sky-600 shrink-0">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-baseline gap-1.5">
|
||||
<span className="text-2xl font-bold text-sky-600 tracking-tight font-geist">
|
||||
{kpis.gondolaEndRate.toFixed(1)}%
|
||||
</span>
|
||||
<span className="text-xs font-bold text-sky-600 font-figtree">
|
||||
({kpis.gondolaEndStores}/{kpis.visitedStores})
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-figtree">
|
||||
Stores with Gondola End
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7. Lucozade Brand Share */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Lucozade Brand Share
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-sky-50 text-sky-600 shrink-0">
|
||||
<Layers className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-sky-600 tracking-tight font-geist">
|
||||
{kpis.avgBrandShelfShare.toFixed(1)}%
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-figtree">
|
||||
Ice Kick vs Lucozade Brand
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 8. Avg Shelf Price */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Avg Shelf Price
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-sky-50 text-sky-600 shrink-0">
|
||||
<Tag className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900 tracking-tight font-geist">
|
||||
${kpis.avgRetailPrice.toFixed(2)}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-figtree">
|
||||
Avg. consumer retail price
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 9. Volume Sell-Out */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Volume Sell-Out
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-amber-50 text-amber-600 shrink-0">
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-amber-600 tracking-tight font-geist">
|
||||
{kpis.totalSellOutUnits.toLocaleString()} <span className="text-xs font-bold text-slate-400 font-figtree">units</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-figtree">
|
||||
Sell-In - Physical Inventory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 10. Value Sell-Out */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Value Sell-Out
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-emerald-50 text-emerald-600 shrink-0">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-emerald-600 tracking-tight font-geist">
|
||||
${kpis.totalSellOutValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-figtree">
|
||||
Volume Sell-Out * Shelf Price
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 11. Physical Inventory */}
|
||||
<div className="bg-white rounded-2xl border border-sky-100/80 p-3.5 shadow-2xs hover:shadow-xs transition-all flex flex-col justify-between">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase tracking-wider font-ibm">
|
||||
Physical Inventory
|
||||
</span>
|
||||
<div className="p-1.5 rounded-lg bg-amber-50 text-amber-600 shrink-0">
|
||||
<PackageCheck className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-slate-900 tracking-tight font-geist">
|
||||
{kpis.totalPhysicalInventory.toLocaleString()} <span className="text-xs font-bold text-slate-400 font-figtree">units</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5 font-figtree">
|
||||
Sales Floor + Backroom
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MergedStoreAudit } from '../types';
|
||||
import {
|
||||
X,
|
||||
MapPin,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Image as ImageIcon,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
|
||||
interface StoreAuditModalProps {
|
||||
item: MergedStoreAudit | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const StoreAuditModal: React.FC<StoreAuditModalProps> = ({
|
||||
item,
|
||||
onClose
|
||||
}) => {
|
||||
if (!item) return null;
|
||||
|
||||
const { store, response, status, hasOOS } = item;
|
||||
const isVisited = status === 'Visited';
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null);
|
||||
|
||||
const allPhotos = [
|
||||
...(response?.shelfPhotos || []).map(url => ({ url, title: 'Shelf / Display Photo' })),
|
||||
...(response?.popPhotos || []).map(url => ({ url, title: 'POP Material Photo' })),
|
||||
...(response?.categoryPhotos || []).map(url => ({ url, title: 'Category Gondola Photo' }))
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950/60 backdrop-blur-sm flex items-center justify-center p-3 sm:p-6 overflow-y-auto">
|
||||
<div className="bg-white rounded-3xl max-w-4xl w-full my-auto overflow-hidden shadow-2xl border border-sky-100">
|
||||
|
||||
{/* Modal Header */}
|
||||
<div className="bg-sky-50/50 p-5 flex items-start justify-between relative border-b border-sky-100">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="bg-amber-100 text-amber-900 border border-amber-200/80 text-[10px] font-bold uppercase tracking-wider px-2.5 py-0.5 rounded-full font-figtree">
|
||||
Audit Report File
|
||||
</span>
|
||||
<span className="text-xs text-slate-400 font-mono">{store.customerCode}</span>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold tracking-tight text-slate-900 font-ibm">{store.customer}</h2>
|
||||
<p className="text-xs text-slate-500 flex items-center gap-1.5 mt-1 font-figtree">
|
||||
<MapPin className="w-3.5 h-3.5 text-sky-600" />
|
||||
{store.address} • <span className="font-semibold text-sky-700">{store.zone}</span> • <span>{store.customerChannel}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-slate-400 hover:text-slate-800 hover:bg-sky-100/60 rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div className="p-5 space-y-6 max-h-[78vh] overflow-y-auto text-slate-700">
|
||||
|
||||
{/* Metadata Row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 bg-sky-50/30 p-3 rounded-2xl border border-sky-100 text-xs">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 uppercase font-bold block">Assigned Auditor</span>
|
||||
<span className="font-bold text-slate-800">{store.auditor || 'Unassigned'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 uppercase font-bold block">Visit Days</span>
|
||||
<span className="font-bold text-slate-800">{store.visitDays?.join(', ') || 'Monday - Friday'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 uppercase font-bold block">Sell-In (Units Sold In)</span>
|
||||
<span className="font-bold font-mono text-sky-600">{(store.unitsSoldIn || 0).toLocaleString()} units</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 uppercase font-bold block">Latest Report Date</span>
|
||||
<span className="font-bold text-slate-800">{response?.submissionDate || 'No report'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All Recorded Weekly Visits History */}
|
||||
{item.allResponses && item.allResponses.length > 0 && (
|
||||
<div className="border border-sky-100 rounded-2xl p-4 bg-sky-50/30">
|
||||
<h3 className="text-xs font-extrabold text-slate-900 mb-2 flex items-center gap-1.5">
|
||||
<Calendar className="w-4 h-4 text-sky-600" />
|
||||
<span>Weekly Visit History for this POS ({item.allResponses.length} total recorded visits)</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{item.allResponses.map((res, idx) => (
|
||||
<div key={res.submissionId || idx} className="bg-white p-3 rounded-xl border border-sky-100 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<div>
|
||||
<span className="font-extrabold text-slate-900 block">{res.submissionDate}</span>
|
||||
<span className="text-slate-500 text-[10px]">{res.placementLocation || 'Main Shelf'} • POP: {res.popVisible || 'None'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold ${res.isAvailable === 'Yes' ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-rose-50 text-rose-700 border border-rose-200'}`}>
|
||||
{res.isAvailable === 'Yes' ? 'Available' : 'Out of Stock'}
|
||||
</span>
|
||||
<span className="font-mono font-bold text-slate-700">${(res.retailPrice || 0).toFixed(2)}</span>
|
||||
<span className="font-mono text-sky-600 font-bold">{res.totalPhysicalInventory || 0} units</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isVisited ? (
|
||||
<div className="p-8 text-center bg-slate-50 rounded-2xl border border-dashed border-slate-200">
|
||||
<p className="text-sm font-bold text-slate-800">Store Pending Audit</p>
|
||||
<p className="text-xs text-slate-500 mt-1">No form response has been recorded for this store yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* STEP 1 */}
|
||||
<div className="border border-sky-100 rounded-2xl p-4 bg-sky-50/20 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-3 border-b border-sky-100 pb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-lg bg-sky-600 text-white font-black text-xs flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900">Step 1: Presence Validation (First Impression)</h3>
|
||||
</div>
|
||||
{response?.isAvailable === 'Yes' ? (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-50 text-emerald-700 border border-emerald-200 px-3 py-1 rounded-full text-xs font-extrabold">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
|
||||
Available for Sale Today (Yes)
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 bg-rose-50 text-rose-700 border border-rose-200 px-3 py-1 rounded-full text-xs font-extrabold">
|
||||
<XCircle className="w-4 h-4 text-rose-600" />
|
||||
Out of Stock (No)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasOOS && (
|
||||
<div className="bg-rose-50 border border-rose-200 text-rose-800 p-3 rounded-xl text-xs font-medium flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4 text-rose-600 shrink-0" />
|
||||
<span>
|
||||
<strong className="text-rose-900">Auditor Alert:</strong> Product not available on sales floor. Proceeded to Step 4 (Backroom) to inspect stored stock.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* STEP 2 */}
|
||||
<div className="border border-sky-100 rounded-2xl p-4 bg-sky-50/20 shadow-2xs">
|
||||
<div className="flex items-center gap-2 mb-3 border-b border-sky-100 pb-2.5">
|
||||
<span className="w-6 h-6 rounded-lg bg-sky-600 text-white font-black text-xs flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900">Step 2: Placement & Display (Store Mapping)</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-xs">
|
||||
<div className="bg-white p-3 rounded-xl border border-sky-100">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block mb-1">Product Location</span>
|
||||
<span className="text-sm font-bold text-slate-900">{response?.placementLocation || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-xl border border-sky-100">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block mb-1">Promotional Material (POP)</span>
|
||||
<span className="text-sm font-bold text-slate-900">{response?.popVisible || 'No POP'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 3 */}
|
||||
<div className="border border-sky-100 rounded-2xl p-4 bg-sky-50/20 shadow-2xs">
|
||||
<div className="flex items-center gap-2 mb-3 border-b border-sky-100 pb-2.5">
|
||||
<span className="w-6 h-6 rounded-lg bg-sky-600 text-white font-black text-xs flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900">Step 3: Main Shelf Measurement (Detailed Work)</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs mb-4">
|
||||
<div className="bg-white p-3 rounded-xl border border-sky-100">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Retail Price</span>
|
||||
<span className="text-base font-black font-mono text-emerald-600">${(response?.retailPrice || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-xl border border-sky-100">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Ice Kick Facings</span>
|
||||
<span className="text-base font-black text-slate-900">{response?.facingsIceKick || 0} facings</span>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-xl border border-sky-100">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Lucozade Brand Share</span>
|
||||
<span className="text-base font-black text-amber-600">
|
||||
{response?.facingsLucozadeBrand && response.facingsLucozadeBrand > 0
|
||||
? (((response.facingsIceKick || 0) / response.facingsLucozadeBrand) * 100).toFixed(1)
|
||||
: 0}%
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-semibold block">({response?.facingsLucozadeBrand || 0} Lucozade facings)</span>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-xl border border-sky-100">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Total Category Share</span>
|
||||
<span className="text-base font-black text-sky-600">
|
||||
{response?.facingsCategoryTotal && response.facingsCategoryTotal > 0
|
||||
? (((response.facingsLucozadeBrand || 0) / response.facingsCategoryTotal) * 100).toFixed(1)
|
||||
: 0}%
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-semibold block">({response?.facingsCategoryTotal || 0} total facings)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 p-3 rounded-xl text-xs">
|
||||
<span className="font-bold text-amber-900 block mb-0.5">Space Substitution (SKU Substitution):</span>
|
||||
<span className="text-amber-800 font-semibold">{response?.skuSubstitution || 'No substitution recorded'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* STEP 4 */}
|
||||
<div className="border border-sky-100 rounded-2xl p-4 bg-sky-50/20 shadow-2xs">
|
||||
<div className="flex items-center justify-between mb-3 border-b border-sky-100 pb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-6 h-6 rounded-lg bg-sky-600 text-white font-black text-xs flex items-center justify-center">
|
||||
4
|
||||
</span>
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900">Step 4: Inventory & Closure (Store Manager Interview)</h3>
|
||||
</div>
|
||||
<span className="text-xs font-extrabold text-emerald-700 bg-emerald-50 border border-emerald-200 px-3 py-1 rounded-full font-mono">
|
||||
Value Sell-Out: ${((response?.totalPhysicalInventory || 0) * (response?.retailPrice || 0)).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
|
||||
<div className="bg-white p-3 rounded-xl border border-sky-100 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Total Physical Inventory</span>
|
||||
<span className="text-xs text-slate-400">Sales Floor + Backroom</span>
|
||||
</div>
|
||||
<span className="text-lg font-black font-mono text-slate-900">
|
||||
{(response?.totalPhysicalInventory || 0).toLocaleString()} units
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-xl border border-sky-100 flex justify-between items-center">
|
||||
<div>
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase block">Total Sell-In</span>
|
||||
<span className="text-xs text-slate-400">Dispatched / Sold-In units</span>
|
||||
</div>
|
||||
<span className="text-lg font-black font-mono text-sky-600">
|
||||
{(store.unitsSoldIn || 0).toLocaleString()} units
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PHOTOS SECTION */}
|
||||
{allPhotos.length > 0 && (
|
||||
<div className="border border-sky-100 rounded-2xl p-4 bg-sky-50/20 shadow-2xs">
|
||||
<h3 className="text-xs sm:text-sm font-extrabold text-slate-900 mb-3 flex items-center gap-2">
|
||||
<ImageIcon className="w-4 h-4 text-sky-600" />
|
||||
Attached Photographs ({allPhotos.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
|
||||
{allPhotos.map((photo, i) => (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedPhoto(photo.url)}
|
||||
className="group relative rounded-xl overflow-hidden border border-sky-100 aspect-square bg-slate-100 cursor-pointer hover:border-sky-500 hover:shadow-xs transition-all"
|
||||
>
|
||||
<img src={photo.url} alt={photo.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||
<div className="absolute inset-0 bg-slate-900/60 opacity-0 group-hover:opacity-100 transition-opacity p-2 flex items-end">
|
||||
<span className="text-[10px] font-bold text-white">{photo.title}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="bg-sky-50/50 border-t border-sky-100 p-4 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 bg-amber-400 hover:bg-amber-300 text-slate-950 font-black rounded-xl text-xs transition-all shadow-2xs cursor-pointer"
|
||||
>
|
||||
Close Report
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Lightbox */}
|
||||
{selectedPhoto && (
|
||||
<div
|
||||
onClick={() => setSelectedPhoto(null)}
|
||||
className="fixed inset-0 z-60 bg-slate-950/80 backdrop-blur-md flex items-center justify-center p-4 cursor-pointer"
|
||||
>
|
||||
<div className="max-w-4xl max-h-[90vh] bg-white border border-sky-100 p-2 rounded-3xl shadow-2xl">
|
||||
<img src={selectedPhoto} alt="Zoom" className="max-w-full max-h-[80vh] object-contain rounded-2xl mx-auto" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import React, { useState } from 'react';
|
||||
import { MergedStoreAudit } from '../types';
|
||||
import { Store, CheckCircle2, XCircle, Clock, Eye, Download } from 'lucide-react';
|
||||
|
||||
interface StoreTableProps {
|
||||
mergedData: MergedStoreAudit[];
|
||||
onSelectStore: (item: MergedStoreAudit) => void;
|
||||
}
|
||||
|
||||
export const StoreTable: React.FC<StoreTableProps> = ({
|
||||
mergedData,
|
||||
onSelectStore
|
||||
}) => {
|
||||
const [sortField, setSortField] = useState<string>('customer');
|
||||
const [sortAsc, setSortAsc] = useState(true);
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortField === field) {
|
||||
setSortAsc(!sortAsc);
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortAsc(true);
|
||||
}
|
||||
};
|
||||
|
||||
const sortedData = [...mergedData].sort((a, b) => {
|
||||
let valA: any = a.store.customer;
|
||||
let valB: any = b.store.customer;
|
||||
|
||||
const resA = a.response;
|
||||
const resB = b.response;
|
||||
|
||||
const priceA = resA?.retailPrice || 0;
|
||||
const priceB = resB?.retailPrice || 0;
|
||||
const invA = resA?.totalPhysicalInventory || 0;
|
||||
const invB = resB?.totalPhysicalInventory || 0;
|
||||
const sellInA = a.store.unitsSoldIn || 0;
|
||||
const sellInB = b.store.unitsSoldIn || 0;
|
||||
const sellOutA = Math.max(0, sellInA - invA);
|
||||
const sellOutB = Math.max(0, sellInB - invB);
|
||||
|
||||
if (sortField === 'zone') {
|
||||
valA = a.store.zone;
|
||||
valB = b.store.zone;
|
||||
} else if (sortField === 'channel') {
|
||||
valA = a.store.customerChannel;
|
||||
valB = b.store.customerChannel;
|
||||
} else if (sortField === 'date') {
|
||||
valA = resA?.submissionDate || a.store.assignedDate || '';
|
||||
valB = resB?.submissionDate || b.store.assignedDate || '';
|
||||
} else if (sortField === 'status') {
|
||||
valA = a.status;
|
||||
valB = b.status;
|
||||
} else if (sortField === 'oos') {
|
||||
valA = resA?.isAvailable || '';
|
||||
valB = resB?.isAvailable || '';
|
||||
} else if (sortField === 'placement') {
|
||||
valA = resA?.placementLocation || '';
|
||||
valB = resB?.placementLocation || '';
|
||||
} else if (sortField === 'pop') {
|
||||
valA = resA?.popVisible || '';
|
||||
valB = resB?.popVisible || '';
|
||||
} else if (sortField === 'price') {
|
||||
valA = priceA;
|
||||
valB = priceB;
|
||||
} else if (sortField === 'iceKickFacings') {
|
||||
valA = resA?.facingsIceKick || 0;
|
||||
valB = resB?.facingsIceKick || 0;
|
||||
} else if (sortField === 'lucozadeFacings') {
|
||||
valA = resA?.facingsLucozadeBrand || 0;
|
||||
valB = resB?.facingsLucozadeBrand || 0;
|
||||
} else if (sortField === 'inventory') {
|
||||
valA = invA;
|
||||
valB = invB;
|
||||
} else if (sortField === 'sellIn') {
|
||||
valA = sellInA;
|
||||
valB = sellInB;
|
||||
} else if (sortField === 'sellOut') {
|
||||
valA = sellOutA;
|
||||
valB = sellOutB;
|
||||
} else if (sortField === 'sellOutValue') {
|
||||
valA = sellOutA * priceA;
|
||||
valB = sellOutB * priceB;
|
||||
}
|
||||
|
||||
if (valA < valB) return sortAsc ? -1 : 1;
|
||||
if (valA > valB) return sortAsc ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const formatCsvCell = (val: string | number | undefined | null): string => {
|
||||
if (val === null || val === undefined) return '""';
|
||||
// Clean up any embedded carriage returns or newlines to keep single-row per store in Excel
|
||||
const cleanStr = String(val).replace(/[\r\n]+/g, ' ').trim();
|
||||
// Escape double quotes according to standard CSV spec (replace " with "")
|
||||
const escaped = cleanStr.replace(/"/g, '""');
|
||||
return `"${escaped}"`;
|
||||
};
|
||||
|
||||
const exportCSV = () => {
|
||||
const headers = [
|
||||
'Punto de Venta',
|
||||
'Fecha Visita',
|
||||
'Zona',
|
||||
'Channel',
|
||||
'Estatus de Auditoría',
|
||||
'OOS Availability',
|
||||
'Placement',
|
||||
'POP',
|
||||
'Price Shelf ($)',
|
||||
'Ice Kick Facings',
|
||||
'Lucozade Facings',
|
||||
'Physical Inventory',
|
||||
'Sell In',
|
||||
'Sell Out',
|
||||
'Sell Out Value ($)'
|
||||
];
|
||||
|
||||
const rows = sortedData.map(m => {
|
||||
const isVisited = m.status === 'Visited';
|
||||
const res = m.response;
|
||||
const price = res?.retailPrice || 0;
|
||||
const inv = res?.totalPhysicalInventory || 0;
|
||||
const sellIn = m.store.unitsSoldIn || 0;
|
||||
const sellOut = isVisited ? Math.max(0, sellIn - inv) : 0;
|
||||
const sellOutVal = sellOut * price;
|
||||
|
||||
return [
|
||||
m.store.customer,
|
||||
res?.submissionDate || m.store.assignedDate || 'Pending',
|
||||
m.store.zone,
|
||||
m.store.customerChannel,
|
||||
isVisited ? 'Visited' : 'Pending',
|
||||
isVisited ? (res?.isAvailable === 'Yes' ? 'Available' : 'Out of Stock') : 'Not Reported',
|
||||
isVisited ? (res?.placementLocation || 'N/A') : 'N/A',
|
||||
isVisited ? (res?.popVisible || 'No POP') : 'N/A',
|
||||
isVisited ? price.toFixed(2) : '0.00',
|
||||
isVisited ? (res?.facingsIceKick || 0) : 0,
|
||||
isVisited ? (res?.facingsLucozadeBrand || 0) : 0,
|
||||
isVisited ? inv : 0,
|
||||
sellIn,
|
||||
isVisited ? sellOut : 0,
|
||||
isVisited ? sellOutVal.toFixed(2) : '0.00'
|
||||
];
|
||||
});
|
||||
|
||||
const csvLines = [
|
||||
headers.map(h => formatCsvCell(h)).join(','),
|
||||
...rows.map(r => r.map(cell => formatCsvCell(cell)).join(','))
|
||||
].join('\r\n');
|
||||
|
||||
// Include UTF-8 BOM (\uFEFF) so Excel, Google Sheets, and Numbers auto-detect encoding and column delimiters
|
||||
const blob = new Blob(['\uFEFF' + csvLines], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `lucozade_audit_master_table_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl border border-sky-100 shadow-2xs overflow-hidden">
|
||||
|
||||
{/* Table Header Controls */}
|
||||
<div className="p-4 border-b border-sky-100 bg-sky-50/40 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-xs sm:text-sm font-bold text-slate-800 flex items-center gap-2 font-ibm">
|
||||
<div className="p-1.5 rounded-lg bg-sky-100 text-sky-700">
|
||||
<Store className="w-4 h-4" />
|
||||
</div>
|
||||
Consolidated Store Audit Directory
|
||||
</h3>
|
||||
<p className="text-2xs text-slate-500 mt-0.5 font-figtree">
|
||||
Complete store audit breakdown across 14 independent key performance indicators.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={exportCSV}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-bold text-slate-700 bg-white hover:bg-slate-50 border border-slate-200 rounded-xl transition-all shadow-2xs cursor-pointer font-figtree"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5 text-slate-500" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table Body - Fira Sans font for directory data */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-xs whitespace-nowrap font-fira">
|
||||
<thead className="bg-sky-50/60 text-slate-500 font-extrabold uppercase text-[10px] tracking-wider border-b border-sky-100">
|
||||
<tr>
|
||||
<th onClick={() => handleSort('customer')} className="px-3.5 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Point of Sale (Store) ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('date')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Visit Date / Week ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('zone')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Zone ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('channel')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Channel ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('status')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Audit Status ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('oos')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
OOS Availability ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('placement')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Placement ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('pop')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
POP ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('price')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Price Shelf ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('iceKickFacings')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Ice Kick Facings ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('lucozadeFacings')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Lucozade Facings ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('inventory')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Physical Inventory ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('sellIn')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors">
|
||||
Sell In ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('sellOut')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors bg-amber-50/50 text-amber-800">
|
||||
Sell Out ↑↓
|
||||
</th>
|
||||
<th onClick={() => handleSort('sellOutValue')} className="px-3 py-3 cursor-pointer hover:bg-sky-100/50 transition-colors bg-emerald-50/50 text-emerald-800">
|
||||
Sell Out Value ↑↓
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-sky-100/60 text-slate-700">
|
||||
{sortedData.map(item => {
|
||||
const res = item.response;
|
||||
const isVisited = item.status === 'Visited';
|
||||
const price = res?.retailPrice || 0;
|
||||
const inv = res?.totalPhysicalInventory || 0;
|
||||
const sellIn = item.store.unitsSoldIn || 0;
|
||||
const sellOut = isVisited ? Math.max(0, sellIn - inv) : 0;
|
||||
const sellOutVal = sellOut * price;
|
||||
|
||||
return (
|
||||
<tr key={`${item.store.customerCode}_${res?.submissionId || 'pending'}`} className="hover:bg-sky-50/30 transition-colors">
|
||||
|
||||
{/* 1. Punto de Venta */}
|
||||
<td className="px-3.5 py-3 font-bold text-slate-900">
|
||||
<div>{item.store.customer}</div>
|
||||
<span className="text-[10px] font-normal text-slate-400 block max-w-[180px] truncate">
|
||||
{item.store.address}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Visit Date / Week */}
|
||||
<td className="px-3 py-3 text-slate-700 font-mono text-[11px]">
|
||||
{res?.submissionDate || item.store.assignedDate || 'Pending'}
|
||||
</td>
|
||||
|
||||
{/* 2. Zona */}
|
||||
<td className="px-3 py-3 font-semibold text-slate-800">
|
||||
{item.store.zone}
|
||||
</td>
|
||||
|
||||
{/* 3. Channel */}
|
||||
<td className="px-3 py-3 text-slate-600">
|
||||
{item.store.customerChannel}
|
||||
</td>
|
||||
|
||||
{/* 4. Estatus de Auditoría */}
|
||||
<td className="px-3 py-3">
|
||||
{isVisited ? (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-50 text-emerald-700 border border-emerald-200 px-2.5 py-0.5 rounded-full font-bold text-[10px]">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-600" />
|
||||
Visited
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 bg-slate-100 text-slate-500 border border-slate-200 px-2.5 py-0.5 rounded-full font-semibold text-[10px]">
|
||||
<Clock className="w-3 h-3 text-slate-400" />
|
||||
Pending
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 5. OOS Availability */}
|
||||
<td className="px-3 py-3">
|
||||
{isVisited ? (
|
||||
res?.isAvailable === 'Yes' ? (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-50 text-emerald-700 border border-emerald-200 px-2 py-0.5 rounded-md font-bold text-[10px]">
|
||||
<CheckCircle2 className="w-3 h-3 text-emerald-600" />
|
||||
Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 bg-rose-50 text-rose-700 border border-rose-200 px-2 py-0.5 rounded-md font-extrabold text-[10px]">
|
||||
<XCircle className="w-3 h-3 text-rose-600" />
|
||||
Out of Stock
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-slate-400 text-[10px]">Not Reported</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* 6. Placement */}
|
||||
<td className="px-3 py-3 font-medium text-slate-800">
|
||||
{isVisited ? (res?.placementLocation || 'N/A') : '-'}
|
||||
</td>
|
||||
|
||||
{/* 7. POP */}
|
||||
<td className="px-3 py-3 font-medium text-slate-800">
|
||||
{isVisited ? (res?.popVisible || 'No POP') : '-'}
|
||||
</td>
|
||||
|
||||
{/* 8. Price Shelf */}
|
||||
<td className="px-3 py-3 font-bold font-mono text-slate-900">
|
||||
{isVisited ? `$${price.toFixed(2)}` : '-'}
|
||||
</td>
|
||||
|
||||
{/* 9. Ice Kick Facings */}
|
||||
<td className="px-3 py-3 font-bold font-mono text-slate-800">
|
||||
{isVisited ? res?.facingsIceKick || 0 : '-'}
|
||||
</td>
|
||||
|
||||
{/* 10. Lucozade Facings */}
|
||||
<td className="px-3 py-3 font-bold font-mono text-slate-800">
|
||||
{isVisited ? res?.facingsLucozadeBrand || 0 : '-'}
|
||||
</td>
|
||||
|
||||
{/* 11. Physical Inventory */}
|
||||
<td className="px-3 py-3 font-black text-slate-900 font-mono">
|
||||
{isVisited ? `${inv.toLocaleString()} units` : '-'}
|
||||
</td>
|
||||
|
||||
{/* 12. Sell In */}
|
||||
<td className="px-3 py-3 font-bold text-sky-600 font-mono">
|
||||
{sellIn.toLocaleString()} units
|
||||
</td>
|
||||
|
||||
{/* 13. Sell Out */}
|
||||
<td className="px-3 py-3 font-black text-amber-600 bg-amber-50/30 font-mono">
|
||||
{isVisited ? `${sellOut.toLocaleString()} units` : '-'}
|
||||
</td>
|
||||
|
||||
{/* 14. Sell Out Value */}
|
||||
<td className="px-3 py-3 font-bold text-emerald-600 bg-emerald-50/20 font-mono">
|
||||
{isVisited ? `$${sellOutVal.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}` : '-'}
|
||||
</td>
|
||||
|
||||
{/* Action */}
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => onSelectStore(item)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-amber-100 hover:bg-amber-200/80 text-amber-950 font-bold border border-amber-200/80 rounded-xl text-xs transition-all shadow-2xs active:scale-95 cursor-pointer"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5 text-amber-800" />
|
||||
<span>Report</span>
|
||||
</button>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X, Database, ExternalLink } from 'lucide-react';
|
||||
import { DEFAULT_SPREADSHEET_ID } from '../services/googleSheets';
|
||||
|
||||
interface SyncSheetModalProps {
|
||||
isOpen: boolean;
|
||||
currentSpreadsheetId: string;
|
||||
source: 'google_sheets_api' | 'google_sheets_csv' | 'fallback_demo';
|
||||
error?: string;
|
||||
onClose: () => void;
|
||||
onUpdateSpreadsheetId: (newId: string) => void;
|
||||
}
|
||||
|
||||
export const SyncSheetModal: React.FC<SyncSheetModalProps> = ({
|
||||
isOpen,
|
||||
currentSpreadsheetId,
|
||||
source,
|
||||
error,
|
||||
onClose,
|
||||
onUpdateSpreadsheetId
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const [inputVal, setInputVal] = useState(currentSpreadsheetId);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (inputVal.trim()) {
|
||||
onUpdateSpreadsheetId(inputVal.trim());
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const sheetUrl = currentSpreadsheetId.startsWith('http')
|
||||
? currentSpreadsheetId
|
||||
: `https://docs.google.com/spreadsheets/d/${currentSpreadsheetId}/edit`;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-slate-950/70 backdrop-blur-xs flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-3xl max-w-lg w-full overflow-hidden shadow-2xl border border-sky-100">
|
||||
|
||||
{/* Header */}
|
||||
<div className="bg-sky-50/70 text-slate-900 p-4 sm:p-5 flex items-center justify-between border-b border-sky-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-xl bg-amber-100 text-amber-800 border border-amber-200/80">
|
||||
<Database className="w-4 h-4" />
|
||||
</div>
|
||||
<h3 className="font-extrabold text-sm text-slate-900">Google Sheets Configuration</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 text-slate-400 hover:text-slate-900 rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-4 text-slate-800">
|
||||
|
||||
<div className="bg-amber-50 border border-amber-200 text-slate-900 p-3.5 rounded-2xl text-xs space-y-1">
|
||||
<p className="font-extrabold text-amber-950">Connected Google Sheets Database:</p>
|
||||
<p className="font-mono text-[11px] break-all bg-white p-2 rounded-xl border border-amber-200/80 text-amber-900 font-bold">
|
||||
{currentSpreadsheetId}
|
||||
</p>
|
||||
<a
|
||||
href={sheetUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-[11px] font-extrabold text-amber-700 hover:text-amber-950 underline pt-1"
|
||||
>
|
||||
Open Google Sheet in new tab <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-[10px] font-extrabold uppercase tracking-wider text-slate-400 mb-1">
|
||||
Google Sheets ID or Published Web Link (pubhtml / URL / ID)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputVal}
|
||||
onChange={e => setInputVal(e.target.value)}
|
||||
placeholder="https://docs.google.com/spreadsheets/d/e/2PACX-.../pubhtml"
|
||||
className="w-full px-3.5 py-2.5 text-xs bg-sky-50/50 border border-sky-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-amber-400 font-mono text-slate-900 placeholder-slate-400 font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-3.5 rounded-2xl border border-sky-100 bg-sky-50/30 text-xs">
|
||||
<span className="font-extrabold text-slate-900 block mb-1">Required Tab Structure:</span>
|
||||
<ul className="list-disc list-inside text-[11px] text-slate-600 space-y-0.5">
|
||||
<li><span className="font-extrabold text-slate-900">Form responses</span>: Audit form responses tab</li>
|
||||
<li><span className="font-extrabold text-slate-900">tiendas</span>: Master stores list tab</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setInputVal(DEFAULT_SPREADSHEET_ID);
|
||||
onUpdateSpreadsheetId(DEFAULT_SPREADSHEET_ID);
|
||||
}}
|
||||
className="text-xs font-bold text-slate-400 hover:text-slate-900 underline cursor-pointer"
|
||||
>
|
||||
Reset Default ID
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3.5 py-2 text-xs font-bold text-slate-500 hover:text-slate-900 hover:bg-sky-50 rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 text-xs font-bold text-amber-950 bg-amber-100 hover:bg-amber-200/80 border border-amber-200/80 rounded-xl shadow-2xs transition-colors cursor-pointer"
|
||||
>
|
||||
Sync
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
import { Store, AuditFormResponse } from '../types';
|
||||
|
||||
export const INITIAL_STORES: Store[] = [
|
||||
// WEEK 1 ASSIGNMENTS (2026-07-20 to 2026-07-26)
|
||||
{
|
||||
customer: "Supermercado El Rey - Calle 50",
|
||||
zone: "Zona Centro",
|
||||
address: "Calle 50 y San Francisco, N° 102",
|
||||
coordinates: "8.9833, -79.5167",
|
||||
customerCode: "CUST-1001",
|
||||
customerChannel: "Supermercados",
|
||||
csSoldIn: 120,
|
||||
unitsSoldIn: 1440,
|
||||
lSoldIn: 720,
|
||||
auditor: "Auditor #01 - Carlos Ruiz",
|
||||
visitDays: ["Monday", "Thursday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Super 99 - Vía Porras",
|
||||
zone: "Zona Centro",
|
||||
address: "Vía Porras y Calle 68, San Francisco",
|
||||
coordinates: "8.9891, -79.5102",
|
||||
customerCode: "CUST-1002",
|
||||
customerChannel: "Supermercados",
|
||||
csSoldIn: 95,
|
||||
unitsSoldIn: 1140,
|
||||
lSoldIn: 570,
|
||||
auditor: "Auditor #01 - Carlos Ruiz",
|
||||
visitDays: ["Monday", "Wednesday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Riba Smith - Bella Vista",
|
||||
zone: "Zona Centro",
|
||||
address: "Av. Justo Arosemena, Bella Vista",
|
||||
coordinates: "8.9744, -79.5298",
|
||||
customerCode: "CUST-1003",
|
||||
customerChannel: "Supermercados",
|
||||
csSoldIn: 150,
|
||||
unitsSoldIn: 1800,
|
||||
lSoldIn: 900,
|
||||
auditor: "Auditor #02 - Ana Gómez",
|
||||
visitDays: ["Tuesday", "Friiday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Minisuper La Bendición - San Miguelito",
|
||||
zone: "Zona Norte",
|
||||
address: "Calle Principal, Sector 3, San Miguelito",
|
||||
coordinates: "9.0333, -79.5000",
|
||||
customerCode: "CUST-1004",
|
||||
customerChannel: "Tradicional",
|
||||
csSoldIn: 30,
|
||||
unitsSoldIn: 360,
|
||||
lSoldIn: 180,
|
||||
auditor: "Auditor #03 - Jorge Mendoza",
|
||||
visitDays: ["Monday", "Wednesday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Tienda Va y Ven - Terpel Brisas",
|
||||
zone: "Zona Este",
|
||||
address: "Av. Manuel E. Batista, Brisas del Golf",
|
||||
coordinates: "9.0512, -79.4520",
|
||||
customerCode: "CUST-1005",
|
||||
customerChannel: "Conveniencia",
|
||||
csSoldIn: 45,
|
||||
unitsSoldIn: 540,
|
||||
lSoldIn: 270,
|
||||
auditor: "Auditor #02 - Ana Gómez",
|
||||
visitDays: ["Tuesday", "Thursday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Farmacias Arrocha - Costa del Este",
|
||||
zone: "Zona Este",
|
||||
address: "Paseo del Mar, Costa del Este",
|
||||
coordinates: "9.0110, -79.4700",
|
||||
customerCode: "CUST-1006",
|
||||
customerChannel: "Farmacias",
|
||||
csSoldIn: 60,
|
||||
unitsSoldIn: 720,
|
||||
lSoldIn: 360,
|
||||
auditor: "Auditor #04 - Luisa Fernández",
|
||||
visitDays: ["Wednesday", "Friiday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Pricesmart - Vía España",
|
||||
zone: "Zona Centro",
|
||||
address: "Vía España y Calle 12, Carrasquilla",
|
||||
coordinates: "8.9950, -79.5080",
|
||||
customerCode: "CUST-1007",
|
||||
customerChannel: "Clubes de Compra",
|
||||
csSoldIn: 300,
|
||||
unitsSoldIn: 3600,
|
||||
lSoldIn: 1800,
|
||||
auditor: "Auditor #01 - Carlos Ruiz",
|
||||
visitDays: ["Monday", "Friiday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Super Carnes - David Chiriquí",
|
||||
zone: "Zona Oeste",
|
||||
address: "Av. Central, David, Chiriquí",
|
||||
coordinates: "8.4273, -82.4308",
|
||||
customerCode: "CUST-1008",
|
||||
customerChannel: "Supermercados",
|
||||
csSoldIn: 110,
|
||||
unitsSoldIn: 1320,
|
||||
lSoldIn: 660,
|
||||
auditor: "Auditor #05 - Roberto Blanco",
|
||||
visitDays: ["Tuesday", "Thursday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Minisuper Express - Chorrera",
|
||||
zone: "Zona Oeste",
|
||||
address: "Av. las Américas, La Chorrera",
|
||||
coordinates: "8.8803, -79.7833",
|
||||
customerCode: "CUST-1009",
|
||||
customerChannel: "Tradicional",
|
||||
csSoldIn: 25,
|
||||
unitsSoldIn: 300,
|
||||
lSoldIn: 150,
|
||||
auditor: "Auditor #05 - Roberto Blanco",
|
||||
visitDays: ["Wednesday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
{
|
||||
customer: "Deli Gourmet - Obarrio",
|
||||
zone: "Zona Centro",
|
||||
address: "Calle 54, Obarrio",
|
||||
coordinates: "8.9865, -79.5190",
|
||||
customerCode: "CUST-1010",
|
||||
customerChannel: "Conveniencia",
|
||||
csSoldIn: 40,
|
||||
unitsSoldIn: 480,
|
||||
lSoldIn: 240,
|
||||
auditor: "Auditor #02 - Ana Gómez",
|
||||
visitDays: ["Monday", "Thursday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-20"
|
||||
},
|
||||
|
||||
// WEEK 2 ASSIGNMENTS (2026-07-27 to 2026-08-02)
|
||||
{
|
||||
customer: "Supermercado El Rey - Calle 50",
|
||||
zone: "Zona Centro",
|
||||
address: "Calle 50 y San Francisco, N° 102",
|
||||
coordinates: "8.9833, -79.5167",
|
||||
customerCode: "CUST-1001",
|
||||
customerChannel: "Supermercados",
|
||||
csSoldIn: 120,
|
||||
unitsSoldIn: 1440,
|
||||
lSoldIn: 720,
|
||||
auditor: "Auditor #01 - Carlos Ruiz",
|
||||
visitDays: ["Monday", "Thursday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-27"
|
||||
},
|
||||
{
|
||||
customer: "Super 99 - Vía Porras",
|
||||
zone: "Zona Centro",
|
||||
address: "Vía Porras y Calle 68, San Francisco",
|
||||
coordinates: "8.9891, -79.5102",
|
||||
customerCode: "CUST-1002",
|
||||
customerChannel: "Supermercados",
|
||||
csSoldIn: 95,
|
||||
unitsSoldIn: 1140,
|
||||
lSoldIn: 570,
|
||||
auditor: "Auditor #01 - Carlos Ruiz",
|
||||
visitDays: ["Monday", "Wednesday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-27"
|
||||
},
|
||||
{
|
||||
customer: "Riba Smith - Bella Vista",
|
||||
zone: "Zona Centro",
|
||||
address: "Av. Justo Arosemena, Bella Vista",
|
||||
coordinates: "8.9744, -79.5298",
|
||||
customerCode: "CUST-1003",
|
||||
customerChannel: "Supermercados",
|
||||
csSoldIn: 150,
|
||||
unitsSoldIn: 1800,
|
||||
lSoldIn: 900,
|
||||
auditor: "Auditor #02 - Ana Gómez",
|
||||
visitDays: ["Tuesday", "Friiday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-27"
|
||||
},
|
||||
{
|
||||
customer: "Minisuper La Bendición - San Miguelito",
|
||||
zone: "Zona Norte",
|
||||
address: "Calle Principal, Sector 3, San Miguelito",
|
||||
coordinates: "9.0333, -79.5000",
|
||||
customerCode: "CUST-1004",
|
||||
customerChannel: "Tradicional",
|
||||
csSoldIn: 30,
|
||||
unitsSoldIn: 360,
|
||||
lSoldIn: 180,
|
||||
auditor: "Auditor #03 - Jorge Mendoza",
|
||||
visitDays: ["Monday", "Wednesday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-27"
|
||||
},
|
||||
{
|
||||
customer: "Farmacias Arrocha - Costa del Este",
|
||||
zone: "Zona Este",
|
||||
address: "Paseo del Mar, Costa del Este",
|
||||
coordinates: "9.0110, -79.4700",
|
||||
customerCode: "CUST-1006",
|
||||
customerChannel: "Farmacias",
|
||||
csSoldIn: 60,
|
||||
unitsSoldIn: 720,
|
||||
lSoldIn: 360,
|
||||
auditor: "Auditor #04 - Luisa Fernández",
|
||||
visitDays: ["Wednesday", "Friiday"],
|
||||
skuDetail: "LUCOZADE SPORT ICE KICK 12/500ML",
|
||||
assignedDate: "2026-07-27"
|
||||
}
|
||||
];
|
||||
|
||||
export const INITIAL_RESPONSES: AuditFormResponse[] = [
|
||||
// WEEK 1 AUDITS (Jul 20 - Jul 26, 2026)
|
||||
{
|
||||
submissionId: "SUB-88201",
|
||||
submissionDate: "2026-07-20 09:15",
|
||||
customer: "Supermercado El Rey - Calle 50",
|
||||
isAvailable: "Yes",
|
||||
placementLocation: "Estante Principal",
|
||||
shelfPhotos: [
|
||||
"https://images.unsplash.com/photo-1578916171728-46686eac8d58?auto=format&fit=crop&w=800&q=80",
|
||||
"https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
popVisible: "Wobblers, Posters, Cenefa en buen estado",
|
||||
popPhotos: [
|
||||
"https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
retailPrice: 2.25,
|
||||
facingsIceKick: 6,
|
||||
facingsLucozadeBrand: 14,
|
||||
facingsCategoryTotal: 48,
|
||||
categoryPhotos: [
|
||||
"https://images.unsplash.com/photo-1622483767028-3f66f32aef97?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
skuSubstitution: "Gatorade Cool Blue 500ml (-2 frentes)",
|
||||
totalPhysicalInventory: 340,
|
||||
lastUpdateDate: "2026-07-20 09:30"
|
||||
},
|
||||
{
|
||||
submissionId: "SUB-88202",
|
||||
submissionDate: "2026-07-20 11:20",
|
||||
customer: "Super 99 - Vía Porras",
|
||||
isAvailable: "Yes",
|
||||
placementLocation: "Exhibición Secundaria",
|
||||
shelfPhotos: [
|
||||
"https://images.unsplash.com/photo-1583258292688-d0213dc5a3a8?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
popVisible: "Poster Promocional y Stopper",
|
||||
popPhotos: [
|
||||
"https://images.unsplash.com/photo-1607082348824-0a96f2a4b9da?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
retailPrice: 2.30,
|
||||
facingsIceKick: 4,
|
||||
facingsLucozadeBrand: 10,
|
||||
facingsCategoryTotal: 40,
|
||||
categoryPhotos: [
|
||||
"https://images.unsplash.com/photo-1534723452862-4c874018d66d?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
skuSubstitution: "Powerade Mountain Blast 500ml (-2 frentes)",
|
||||
totalPhysicalInventory: 185,
|
||||
lastUpdateDate: "2026-07-20 11:45"
|
||||
},
|
||||
{
|
||||
submissionId: "SUB-88203",
|
||||
submissionDate: "2026-07-21 08:45",
|
||||
customer: "Riba Smith - Bella Vista",
|
||||
isAvailable: "Yes",
|
||||
placementLocation: "Cabecera de Góndola",
|
||||
shelfPhotos: [
|
||||
"https://images.unsplash.com/photo-1604719312566-8912e9227c6a?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
popVisible: "Wobbler y Cabecera Decorada Completa",
|
||||
popPhotos: [
|
||||
"https://images.unsplash.com/photo-1507679799987-c73779587ccf?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
retailPrice: 2.50,
|
||||
facingsIceKick: 8,
|
||||
facingsLucozadeBrand: 18,
|
||||
facingsCategoryTotal: 52,
|
||||
categoryPhotos: [
|
||||
"https://images.unsplash.com/photo-1578916171728-46686eac8d58?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
skuSubstitution: "Red Bull 250ml Regular (-3 frentes)",
|
||||
totalPhysicalInventory: 420,
|
||||
lastUpdateDate: "2026-07-21 09:05"
|
||||
},
|
||||
{
|
||||
submissionId: "SUB-88204",
|
||||
submissionDate: "2026-07-21 10:10",
|
||||
customer: "Minisuper La Bendición - San Miguelito",
|
||||
isAvailable: "No", // OUT OF STOCK!
|
||||
placementLocation: "N/A - Agotado",
|
||||
shelfPhotos: [],
|
||||
popVisible: "Sin material POP visible",
|
||||
popPhotos: [],
|
||||
retailPrice: 0,
|
||||
facingsIceKick: 0,
|
||||
facingsLucozadeBrand: 2,
|
||||
facingsCategoryTotal: 18,
|
||||
categoryPhotos: [
|
||||
"https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
skuSubstitution: "N/A - Producto sin stock",
|
||||
totalPhysicalInventory: 0,
|
||||
lastUpdateDate: "2026-07-21 10:20"
|
||||
},
|
||||
{
|
||||
submissionId: "SUB-88205",
|
||||
submissionDate: "2026-07-21 14:00",
|
||||
customer: "Tienda Va y Ven - Terpel Brisas",
|
||||
isAvailable: "Yes",
|
||||
placementLocation: "Estante Principal",
|
||||
shelfPhotos: [
|
||||
"https://images.unsplash.com/photo-1583258292688-d0213dc5a3a8?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
popVisible: "Stopper de Nevera y Sticker de Precio",
|
||||
popPhotos: [
|
||||
"https://images.unsplash.com/photo-1526170375885-4d8ecf77b99f?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
retailPrice: 2.60,
|
||||
facingsIceKick: 3,
|
||||
facingsLucozadeBrand: 6,
|
||||
facingsCategoryTotal: 24,
|
||||
categoryPhotos: [
|
||||
"https://images.unsplash.com/photo-1534723452862-4c874018d66d?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
skuSubstitution: "Monster Energy Original 473ml (-1 frente)",
|
||||
totalPhysicalInventory: 64,
|
||||
lastUpdateDate: "2026-07-21 14:15"
|
||||
},
|
||||
|
||||
// WEEK 2 AUDITS (Jul 27 - Aug 02, 2026)
|
||||
{
|
||||
submissionId: "SUB-88211",
|
||||
submissionDate: "2026-07-27 10:00",
|
||||
customer: "Supermercado El Rey - Calle 50",
|
||||
isAvailable: "Yes",
|
||||
placementLocation: "Estante Principal",
|
||||
shelfPhotos: [
|
||||
"https://images.unsplash.com/photo-1578916171728-46686eac8d58?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
popVisible: "Wobblers y Cenefa",
|
||||
popPhotos: [],
|
||||
retailPrice: 2.25,
|
||||
facingsIceKick: 8,
|
||||
facingsLucozadeBrand: 16,
|
||||
facingsCategoryTotal: 50,
|
||||
categoryPhotos: [
|
||||
"https://images.unsplash.com/photo-1622483767028-3f66f32aef97?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
skuSubstitution: "Gatorade Cool Blue (-2 frentes)",
|
||||
totalPhysicalInventory: 310,
|
||||
lastUpdateDate: "2026-07-27 10:20"
|
||||
},
|
||||
{
|
||||
submissionId: "SUB-88212",
|
||||
submissionDate: "2026-07-28 11:30",
|
||||
customer: "Super 99 - Vía Porras",
|
||||
isAvailable: "No", // OOS IN WEEK 2
|
||||
placementLocation: "N/A - Agotado",
|
||||
shelfPhotos: [],
|
||||
popVisible: "Poster Promocional",
|
||||
popPhotos: [],
|
||||
retailPrice: 0,
|
||||
facingsIceKick: 0,
|
||||
facingsLucozadeBrand: 8,
|
||||
facingsCategoryTotal: 42,
|
||||
categoryPhotos: [
|
||||
"https://images.unsplash.com/photo-1534723452862-4c874018d66d?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
skuSubstitution: "N/A - Sin stock",
|
||||
totalPhysicalInventory: 0,
|
||||
lastUpdateDate: "2026-07-28 11:45"
|
||||
},
|
||||
{
|
||||
submissionId: "SUB-88213",
|
||||
submissionDate: "2026-07-28 15:10",
|
||||
customer: "Minisuper La Bendición - San Miguelito",
|
||||
isAvailable: "Yes", // RESTOCKED IN WEEK 2!
|
||||
placementLocation: "Estante Principal",
|
||||
shelfPhotos: [
|
||||
"https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
popVisible: "Poster reubicado",
|
||||
popPhotos: [],
|
||||
retailPrice: 2.20,
|
||||
facingsIceKick: 3,
|
||||
facingsLucozadeBrand: 6,
|
||||
facingsCategoryTotal: 20,
|
||||
categoryPhotos: [
|
||||
"https://images.unsplash.com/photo-1542838132-92c53300491e?auto=format&fit=crop&w=800&q=80"
|
||||
],
|
||||
skuSubstitution: "Gatorade (-1 frente)",
|
||||
totalPhysicalInventory: 85,
|
||||
lastUpdateDate: "2026-07-28 15:25"
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
@import "tailwindcss";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Figtree:ital,wght@0,300..900;1,300..900&family=Fira+Sans:ital,wght@0,300;0,400;0,500;0,600;0,700&family=Geist:wght@100..900&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;0,700&display=swap');
|
||||
|
||||
@theme {
|
||||
--font-geist: 'Geist', sans-serif;
|
||||
--font-ibm: 'IBM Plex Sans', sans-serif;
|
||||
--font-figtree: 'Figtree', sans-serif;
|
||||
--font-fira: 'Fira Sans', sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
font-family: 'Figtree', sans-serif;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
button, input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.font-geist {
|
||||
font-family: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.font-ibm {
|
||||
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.font-figtree {
|
||||
font-family: 'Figtree', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.font-fira {
|
||||
font-family: 'Fira Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,586 @@
|
||||
import Papa from 'papaparse';
|
||||
import { Store, AuditFormResponse, MergedStoreAudit } from '../types';
|
||||
import { INITIAL_STORES, INITIAL_RESPONSES } from '../data/mockData';
|
||||
import { parseDateString } from '../utils/dateUtils';
|
||||
|
||||
export const DEFAULT_SPREADSHEET_ID = "https://docs.google.com/spreadsheets/d/e/2PACX-1vQL9z6VLMOImLy33neZsu4iT6gLJe9pUv3hukhF5uq_RQ71musz6y4k4ljV4ywYcUZLu25NcnaDw_Mw/pubhtml";
|
||||
|
||||
export interface FetchResult {
|
||||
stores: Store[];
|
||||
responses: AuditFormResponse[];
|
||||
merged: MergedStoreAudit[];
|
||||
lastSynced: string;
|
||||
source: 'google_sheets_api' | 'google_sheets_csv' | 'fallback_demo';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Utility for header column value search with exact match priority
|
||||
function getColValue(row: Record<string, any>, keywords: string[]): string {
|
||||
if (!row) return '';
|
||||
const keys = Object.keys(row);
|
||||
|
||||
// 1. Exact match pass (case-insensitive, trimmed)
|
||||
for (const keyword of keywords) {
|
||||
const kw = keyword.toLowerCase().trim();
|
||||
const exactKey = keys.find(k => k.toLowerCase().trim() === kw);
|
||||
if (exactKey && row[exactKey] !== undefined && row[exactKey] !== null) {
|
||||
const val = String(row[exactKey]).trim();
|
||||
if (val !== '') return val;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Partial match pass (includes)
|
||||
for (const keyword of keywords) {
|
||||
const kw = keyword.toLowerCase().trim();
|
||||
const matchedKey = keys.find(k => k.toLowerCase().trim().includes(kw));
|
||||
if (matchedKey && row[matchedKey] !== undefined && row[matchedKey] !== null) {
|
||||
const val = String(row[matchedKey]).trim();
|
||||
if (val !== '') return val;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function parseNumber(val: string): number {
|
||||
if (!val) return 0;
|
||||
const cleaned = val.replace(/[^0-9.-]+/g, '');
|
||||
const num = parseFloat(cleaned);
|
||||
return isNaN(num) ? 0 : num;
|
||||
}
|
||||
|
||||
function normalizeName(str?: string): string {
|
||||
if (!str || typeof str !== 'string') return '';
|
||||
return str
|
||||
.toLowerCase()
|
||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
// Parse 'tiendas' sheet rows
|
||||
export function parseStoresSheet(rows: any[]): Store[] {
|
||||
if (!rows || rows.length === 0) return [];
|
||||
|
||||
return rows.map((row, idx) => {
|
||||
const customer = getColValue(row, ['Customer', 'Punto de venta', 'Tienda', 'Store', 'Nombre', 'Punto de Venta', 'Cliente', 'cliente']) || `Store #${idx + 1}`;
|
||||
const zone = getColValue(row, ['Zone', 'Zona']) || 'General Zone';
|
||||
const address = getColValue(row, ['Address', 'Direccion', 'Dirección']) || 'N/A';
|
||||
const coordinates = getColValue(row, ['Coordinates', 'Coordenadas']) || '';
|
||||
const customerCode = getColValue(row, ['Customer Code', 'Codigo cliente', 'Código', 'Code', 'Codigo']) || `CUST-${1000 + idx}`;
|
||||
const customerChannel = getColValue(row, ['Customer Channel', 'Canal', 'Channel']) || 'General';
|
||||
const csSoldIn = parseNumber(getColValue(row, ['Cs Sold In', 'Cajas']));
|
||||
const unitsSoldIn = parseNumber(getColValue(row, ['Units Sold In', 'Unidades']));
|
||||
const lSoldIn = parseNumber(getColValue(row, ['L Sold In', 'Litros']));
|
||||
const auditor = getColValue(row, ['Auditor #', 'Auditor']) || 'Assigned Auditor';
|
||||
|
||||
const visitDays: string[] = [];
|
||||
if (getColValue(row, ['Monday', 'Lunes']).toLowerCase().includes('y') || getColValue(row, ['Monday']).length > 0) visitDays.push('Monday');
|
||||
if (getColValue(row, ['Tuesday', 'Martes']).toLowerCase().includes('y') || getColValue(row, ['Tuesday']).length > 0) visitDays.push('Tuesday');
|
||||
if (getColValue(row, ['Wednesday', 'Miercoles']).toLowerCase().includes('y') || getColValue(row, ['Wednesday']).length > 0) visitDays.push('Wednesday');
|
||||
if (getColValue(row, ['Thursday', 'Jueves']).toLowerCase().includes('y') || getColValue(row, ['Thursday']).length > 0) visitDays.push('Thursday');
|
||||
if (getColValue(row, ['Friiday', 'Friday', 'Viernes']).toLowerCase().includes('y') || getColValue(row, ['Friiday', 'Friday']).length > 0) visitDays.push('Friiday');
|
||||
|
||||
const skuDetail = getColValue(row, ['LUCOZADE SPORT', 'SKU', 'Lemon Lime']);
|
||||
const assignedDate = getColValue(row, [
|
||||
'Fecha Programada',
|
||||
'Fecha de Visita',
|
||||
'Fecha Visita',
|
||||
'Fecha de Asignación',
|
||||
'Fecha de asignacion',
|
||||
'Fecha Asignada',
|
||||
'Fecha Inicio',
|
||||
'Assigned Date',
|
||||
'Fecha',
|
||||
'Date',
|
||||
'fecha'
|
||||
]);
|
||||
const week = getColValue(row, ['Week', 'week', 'SEMANA', 'Semana', 'Semanas', 'semana', 'Semana de auditoría', 'Semana Auditoría', 'Semana de Auditoria', 'semana_de_auditoria']);
|
||||
|
||||
return {
|
||||
customer,
|
||||
zone,
|
||||
address,
|
||||
coordinates,
|
||||
customerCode,
|
||||
customerChannel,
|
||||
csSoldIn,
|
||||
unitsSoldIn,
|
||||
lSoldIn,
|
||||
auditor,
|
||||
visitDays,
|
||||
skuDetail,
|
||||
assignedDate,
|
||||
week
|
||||
};
|
||||
}).filter(s => s.customer && s.customer !== 'N/A');
|
||||
}
|
||||
|
||||
// Parse 'Form responses' sheet rows
|
||||
export function parseResponsesSheet(rows: any[]): AuditFormResponse[] {
|
||||
if (!rows || rows.length === 0) return [];
|
||||
|
||||
return rows.map((row, idx) => {
|
||||
const submissionId = getColValue(row, ['Submission ID', 'ID', 'Id', 'Timestamp', 'Marca temporal']) || `SUB-${Date.now()}-${idx}`;
|
||||
const submissionDate = getColValue(row, [
|
||||
'Marca temporal',
|
||||
'Timestamp',
|
||||
'Submission Date',
|
||||
'Fecha de Respuesta',
|
||||
'Fecha de Formulario',
|
||||
'Fecha de envío',
|
||||
'Fecha de envio',
|
||||
'Fecha de auditoría',
|
||||
'Fecha de auditoria',
|
||||
'Fecha',
|
||||
'Date',
|
||||
'fecha'
|
||||
]) || new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||
const customer = getColValue(row, ['Dynamic Dropdowns', 'Punto de venta', 'Customer', 'Tienda', 'Store', 'Nombre', 'Punto de Venta', 'Cliente', 'cliente']) || '';
|
||||
|
||||
// Availability parsing with fuzzy keywords
|
||||
const availVal = getColValue(row, [
|
||||
'available',
|
||||
'available for sale',
|
||||
'Is Lucozade Sport Ice Kick available',
|
||||
'Ice Kick available',
|
||||
'Agotado',
|
||||
'Disponible',
|
||||
'Producto disponible',
|
||||
'isAvailable'
|
||||
]);
|
||||
const availLower = (availVal || '').toLowerCase();
|
||||
|
||||
let isAvailable: 'Yes' | 'No' = 'No';
|
||||
if (availLower.includes('yes') || availLower.includes('si') || availLower.includes('sí') || availLower === '1' || availLower === 'true' || availLower.includes('disponible')) {
|
||||
isAvailable = 'Yes';
|
||||
} else if (availLower.includes('no') || availLower === '0' || availLower === 'false' || availLower.includes('agotado')) {
|
||||
isAvailable = 'No';
|
||||
} else {
|
||||
// Check if any value in the row equals yes/si
|
||||
const rowStr = JSON.stringify(row).toLowerCase();
|
||||
if (rowStr.includes('"yes"') || rowStr.includes('disponible') || rowStr.includes('"si"')) {
|
||||
isAvailable = 'Yes';
|
||||
}
|
||||
}
|
||||
|
||||
const placementLocation = getColValue(row, ['Where is the product located', 'Ubicacion', 'Ubicación', 'Location', 'Placement', 'Lugar']) || 'Main Shelf';
|
||||
|
||||
// Helper to parse multiple URLs separated by newlines, spaces, commas, etc.
|
||||
const parsePhotoUrls = (raw: string): string[] => {
|
||||
if (!raw) return [];
|
||||
// Split on newlines, whitespace, commas, semicolons
|
||||
const parts = raw.split(/[\r\n,;\s]+/);
|
||||
return parts.map(s => s.trim()).filter(s => s.startsWith('http://') || s.startsWith('https://'));
|
||||
};
|
||||
|
||||
// Photo URLs
|
||||
const shelfPhotosRaw = getColValue(row, ['photos of the shelf', 'Add photos of the shelf', 'Fotos de estante', 'Shelf photos']);
|
||||
const shelfPhotos = parsePhotoUrls(shelfPhotosRaw);
|
||||
|
||||
const popVisible = getColValue(row, ['Is promotional (POP) material visible', 'POP material', 'Material POP', 'POP visible']) || 'Not Reported';
|
||||
|
||||
const popPhotosRaw = getColValue(row, ['photos of the material POP', 'Add photos of the material POP', 'Fotos POP']);
|
||||
const popPhotos = parsePhotoUrls(popPhotosRaw);
|
||||
|
||||
const retailPrice = parseNumber(getColValue(row, ['exact retail price', 'Consumer Price', 'Precio', 'Price', 'Retail Price']));
|
||||
const facingsIceKick = parseNumber(getColValue(row, ['facings of Lucozade Ice Kick', 'Facings (Ice Kick)', 'Facings Ice Kick', 'Ice Kick Facings']));
|
||||
const facingsLucozadeBrand = parseNumber(getColValue(row, ['facings of the entire Lucozade brand', 'Share of Brand Shelf', 'Facings Lucozade', 'Brand Facings']));
|
||||
const facingsCategoryTotal = parseNumber(getColValue(row, ['facings of the entire energy and sports drink category', 'Share of Shelf by Facings', 'Total Category Facings', 'Category Facings']));
|
||||
|
||||
const categoryPhotosRaw = getColValue(row, ['photos of the Energy & Sports Drinks shelf', 'Add photos of the Energy', 'Category Photos']);
|
||||
const categoryPhotos = parsePhotoUrls(categoryPhotosRaw);
|
||||
|
||||
const skuSubstitution = getColValue(row, ['Which brand/SKU gave up shelf space', 'SKU Substitution', 'Sustitución', 'Sustitucion']) || 'None / N/A';
|
||||
const totalPhysicalInventory = parseNumber(getColValue(row, ['Total Physical Inventory', 'Inventario Fisico', 'Inventario Físico', 'Physical Inventory', 'Inventory']));
|
||||
const lastUpdateDate = getColValue(row, ['Last Update Date', 'Ultima actualizacion', 'Última actualización']) || submissionDate;
|
||||
const week = getColValue(row, ['Week', 'week', 'SEMANA', 'Semana', 'Semanas', 'semana', 'Semana de auditoría', 'Semana Auditoría', 'Semana de Auditoria', 'semana_de_auditoria']);
|
||||
|
||||
return {
|
||||
submissionId,
|
||||
submissionDate,
|
||||
customer,
|
||||
isAvailable,
|
||||
placementLocation,
|
||||
shelfPhotos,
|
||||
popVisible,
|
||||
popPhotos,
|
||||
retailPrice,
|
||||
facingsIceKick,
|
||||
facingsLucozadeBrand,
|
||||
facingsCategoryTotal,
|
||||
categoryPhotos,
|
||||
skuSubstitution,
|
||||
totalPhysicalInventory,
|
||||
lastUpdateDate,
|
||||
week
|
||||
};
|
||||
}).filter(r => r.customer);
|
||||
}
|
||||
|
||||
function parseSubmissionTime(dateStr?: string): number {
|
||||
if (!dateStr) return 0;
|
||||
const d = parseDateString(dateStr);
|
||||
return d ? d.getTime() : 0;
|
||||
}
|
||||
|
||||
function cleanStoreName(str?: string): string {
|
||||
if (!str || typeof str !== 'string') return '';
|
||||
return str
|
||||
.toLowerCase()
|
||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/\b(supermercado|minisuper|tienda|farmacia|arrocha|super|express|autoservicio|despensa|abarrotes|sucursal|suc|no|n°|num|numero|vial)\b/gi, '')
|
||||
.replace(/[^a-z0-9]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function calculateTokenOverlap(str1: string, str2: string): number {
|
||||
const norm1 = normalizeName(str1);
|
||||
const norm2 = normalizeName(str2);
|
||||
if (!norm1 || !norm2) return 0;
|
||||
if (norm1 === norm2) return 1.0;
|
||||
|
||||
const tokens1 = new Set(norm1.split(' ').filter(t => t.length > 1));
|
||||
const tokens2 = new Set(norm2.split(' ').filter(t => t.length > 1));
|
||||
if (tokens1.size === 0 || tokens2.size === 0) return 0;
|
||||
|
||||
let intersection = 0;
|
||||
tokens1.forEach(t => {
|
||||
if (tokens2.has(t)) intersection++;
|
||||
});
|
||||
|
||||
const union = new Set([...tokens1, ...tokens2]).size;
|
||||
return union > 0 ? intersection / union : 0;
|
||||
}
|
||||
|
||||
// Combine Stores and Responses by matching exact Customer name or Customer Code, plus fuzzy fallback
|
||||
export function mergeAuditData(stores: Store[], responses: AuditFormResponse[]): MergedStoreAudit[] {
|
||||
if (!stores || stores.length === 0) return [];
|
||||
|
||||
const storeResponsesMap = new Map<number, AuditFormResponse[]>();
|
||||
const assignedResponseIds = new Set<string>();
|
||||
|
||||
const attach = (storeIdx: number, resp: AuditFormResponse) => {
|
||||
if (!storeResponsesMap.has(storeIdx)) {
|
||||
storeResponsesMap.set(storeIdx, []);
|
||||
}
|
||||
storeResponsesMap.get(storeIdx)!.push(resp);
|
||||
assignedResponseIds.add(resp.submissionId);
|
||||
};
|
||||
|
||||
const storeMeta = stores.map((s, idx) => ({
|
||||
idx,
|
||||
store: s,
|
||||
normName: normalizeName(s.customer),
|
||||
codeNorm: normalizeName(s.customerCode),
|
||||
cleanName: cleanStoreName(s.customer)
|
||||
}));
|
||||
|
||||
// PASS 1: Exact match on Customer Name or Customer Code
|
||||
responses.forEach(resp => {
|
||||
if (assignedResponseIds.has(resp.submissionId)) return;
|
||||
const respNorm = normalizeName(resp.customer);
|
||||
const respCode = normalizeName((resp as any).customerCode);
|
||||
|
||||
const match = storeMeta.find(m =>
|
||||
(respNorm && m.normName === respNorm) ||
|
||||
(respCode && m.codeNorm && respCode === m.codeNorm)
|
||||
);
|
||||
|
||||
if (match) {
|
||||
attach(match.idx, resp);
|
||||
}
|
||||
});
|
||||
|
||||
// PASS 2: Cleaned Name match or Substring/Inclusion Match
|
||||
responses.forEach(resp => {
|
||||
if (assignedResponseIds.has(resp.submissionId)) return;
|
||||
const respNorm = normalizeName(resp.customer);
|
||||
const respClean = cleanStoreName(resp.customer);
|
||||
|
||||
const match = storeMeta.find(m => {
|
||||
if (respClean && m.cleanName && respClean === m.cleanName) return true;
|
||||
if (respNorm.length >= 4 && m.normName.length >= 4) {
|
||||
if (respNorm.includes(m.normName) || m.normName.includes(respNorm)) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
if (match) {
|
||||
attach(match.idx, resp);
|
||||
}
|
||||
});
|
||||
|
||||
// PASS 3: Token Overlap Similarity (>= 35%)
|
||||
responses.forEach(resp => {
|
||||
if (assignedResponseIds.has(resp.submissionId)) return;
|
||||
const respNorm = normalizeName(resp.customer);
|
||||
if (!respNorm) return;
|
||||
|
||||
let bestMatchIdx = -1;
|
||||
let maxOverlap = 0;
|
||||
|
||||
storeMeta.forEach(m => {
|
||||
const overlap = calculateTokenOverlap(resp.customer, m.store.customer);
|
||||
if (overlap > maxOverlap && overlap >= 0.35) {
|
||||
maxOverlap = overlap;
|
||||
bestMatchIdx = m.idx;
|
||||
}
|
||||
});
|
||||
|
||||
if (bestMatchIdx !== -1) {
|
||||
attach(bestMatchIdx, resp);
|
||||
}
|
||||
});
|
||||
|
||||
// PASS 4: Fallback for any remaining unassigned responses to guarantee all visits count
|
||||
responses.forEach(resp => {
|
||||
if (assignedResponseIds.has(resp.submissionId)) return;
|
||||
|
||||
const unvisitedCandidate = storeMeta.find(m => !storeResponsesMap.has(m.idx));
|
||||
if (unvisitedCandidate) {
|
||||
attach(unvisitedCandidate.idx, resp);
|
||||
} else {
|
||||
attach(0, resp);
|
||||
}
|
||||
});
|
||||
|
||||
return stores.map((store, idx) => {
|
||||
const storeResponses = storeResponsesMap.get(idx) || [];
|
||||
|
||||
// Sort responses by Submission Date ascending
|
||||
const sortedResponses = [...storeResponses].sort((a, b) => {
|
||||
const timeA = parseSubmissionTime(a.submissionDate || a.lastUpdateDate);
|
||||
const timeB = parseSubmissionTime(b.submissionDate || b.lastUpdateDate);
|
||||
return timeA - timeB;
|
||||
});
|
||||
|
||||
// Take the latest response for this store based on Submission Date
|
||||
const latestResponse = sortedResponses.length > 0
|
||||
? sortedResponses[sortedResponses.length - 1]
|
||||
: undefined;
|
||||
|
||||
const isVisited = !!latestResponse;
|
||||
const hasOOS = isVisited ? latestResponse?.isAvailable === 'No' : false;
|
||||
|
||||
const facingsIceKick = latestResponse?.facingsIceKick || 0;
|
||||
const facingsBrand = latestResponse?.facingsLucozadeBrand || 0;
|
||||
const facingsCat = latestResponse?.facingsCategoryTotal || 0;
|
||||
|
||||
const shareOfBrandShelf = facingsBrand > 0 ? (facingsIceKick / facingsBrand) * 100 : 0;
|
||||
const shareOfCategoryShelf = facingsCat > 0 ? (facingsBrand / facingsCat) * 100 : 0;
|
||||
|
||||
return {
|
||||
store,
|
||||
response: latestResponse,
|
||||
allResponses: sortedResponses,
|
||||
status: isVisited ? 'Visited' : 'Pending',
|
||||
hasOOS,
|
||||
shareOfBrandShelf,
|
||||
shareOfCategoryShelf
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function getCandidateCsvUrls(spreadsheetIdOrUrl: string, sheetName: string): string[] {
|
||||
let clean = spreadsheetIdOrUrl.trim();
|
||||
const urls: string[] = [];
|
||||
|
||||
let pubKey = '';
|
||||
if (clean.includes('/d/e/')) {
|
||||
const match = clean.match(/\/d\/e\/([a-zA-Z0-9-_]+)/);
|
||||
if (match && match[1]) pubKey = match[1];
|
||||
} else if (clean.startsWith('2PACX-')) {
|
||||
pubKey = clean;
|
||||
}
|
||||
|
||||
if (pubKey) {
|
||||
urls.push(`https://docs.google.com/spreadsheets/d/e/${pubKey}/pub?output=csv&sheet=${encodeURIComponent(sheetName)}`);
|
||||
urls.push(`https://docs.google.com/spreadsheets/d/e/${pubKey}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(sheetName)}`);
|
||||
return urls;
|
||||
}
|
||||
|
||||
if (clean.includes('/spreadsheets/d/')) {
|
||||
const match = clean.match(/\/spreadsheets\/d\/([a-zA-Z0-9-_]+)/);
|
||||
if (match && match[1]) clean = match[1];
|
||||
}
|
||||
|
||||
urls.push(`https://docs.google.com/spreadsheets/d/${clean}/gviz/tq?tqx=out:csv&sheet=${encodeURIComponent(sheetName)}`);
|
||||
urls.push(`https://docs.google.com/spreadsheets/d/${clean}/pub?output=csv&sheet=${encodeURIComponent(sheetName)}`);
|
||||
|
||||
return urls;
|
||||
}
|
||||
|
||||
// Fetch helper via CSV with pubhtml GID auto-resolution
|
||||
async function fetchSheetCSV(spreadsheetId: string, sheetName: string): Promise<any[]> {
|
||||
let clean = spreadsheetId.trim();
|
||||
|
||||
// Extract pubKey if /d/e/ format or starts with 2PACX-
|
||||
let pubKey = '';
|
||||
if (clean.includes('/d/e/')) {
|
||||
const match = clean.match(/\/d\/e\/([a-zA-Z0-9-_]+)/);
|
||||
if (match && match[1]) pubKey = match[1];
|
||||
} else if (clean.startsWith('2PACX-')) {
|
||||
pubKey = clean;
|
||||
}
|
||||
|
||||
// If pubKey exists, fetch pubhtml to resolve exact GID for sheetName
|
||||
if (pubKey) {
|
||||
try {
|
||||
const pubHtmlUrl = `https://docs.google.com/spreadsheets/d/e/${pubKey}/pubhtml`;
|
||||
const htmlRes = await fetch(pubHtmlUrl);
|
||||
if (htmlRes.ok) {
|
||||
const html = await htmlRes.text();
|
||||
const regex = /items\.push\(\{name:\s*"([^"]+)",[^}]*gid:\s*"([^"]+)"/g;
|
||||
let match;
|
||||
let foundGid = '';
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
if (match[1].trim().toLowerCase() === sheetName.trim().toLowerCase()) {
|
||||
foundGid = match[2];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundGid) {
|
||||
const directCsvUrl = `https://docs.google.com/spreadsheets/d/e/${pubKey}/pub?gid=${foundGid}&single=true&output=csv`;
|
||||
const csvRes = await fetch(directCsvUrl);
|
||||
if (csvRes.ok) {
|
||||
const csvText = await csvRes.text();
|
||||
if (csvText && !csvText.includes('<!DOCTYPE html')) {
|
||||
const data: any[] = await new Promise((resolve, reject) => {
|
||||
Papa.parse(csvText, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => resolve(results.data),
|
||||
error: (err) => reject(err)
|
||||
});
|
||||
});
|
||||
if (data && data.length > 0) return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('pubhtml resolution failed, trying fallback candidates...', e);
|
||||
}
|
||||
}
|
||||
|
||||
const candidateUrls = getCandidateCsvUrls(spreadsheetId, sheetName);
|
||||
let lastError: any = null;
|
||||
|
||||
for (const url of candidateUrls) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) continue;
|
||||
const csvText = await res.text();
|
||||
|
||||
if (!csvText || csvText.includes('<!DOCTYPE html') || csvText.includes('<html')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const data: any[] = await new Promise((resolve, reject) => {
|
||||
Papa.parse(csvText, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => resolve(results.data),
|
||||
error: (err) => reject(err)
|
||||
});
|
||||
});
|
||||
|
||||
if (data && data.length > 0) {
|
||||
return data;
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No se pudo obtener datos CSV de la pestaña "${sheetName}".`);
|
||||
}
|
||||
|
||||
// Fetch helper via Google Sheets API v4 with OAuth token
|
||||
async function fetchSheetAPIv4(spreadsheetId: string, sheetName: string, accessToken: string): Promise<any[]> {
|
||||
const url = `https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${encodeURIComponent(sheetName)}?valueRenderOption=FORMATTED_VALUE`;
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
if (!res.ok) throw new Error(`Google Sheets API Error: ${res.statusText}`);
|
||||
const json = await res.json();
|
||||
const values: string[][] = json.values || [];
|
||||
if (values.length < 2) return [];
|
||||
|
||||
const headers = values[0];
|
||||
const rows = values.slice(1);
|
||||
|
||||
return rows.map(row => {
|
||||
const obj: Record<string, string> = {};
|
||||
headers.forEach((h, i) => {
|
||||
obj[h] = row[i] || '';
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGoogleSheetsData(
|
||||
spreadsheetId: string = DEFAULT_SPREADSHEET_ID,
|
||||
accessToken?: string | null
|
||||
): Promise<FetchResult> {
|
||||
const now = new Date().toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
|
||||
// 1. Attempt API v4 if token exists
|
||||
if (accessToken) {
|
||||
try {
|
||||
const storesRaw = await fetchSheetAPIv4(spreadsheetId, 'tiendas', accessToken);
|
||||
const responsesRaw = await fetchSheetAPIv4(spreadsheetId, 'Form responses', accessToken);
|
||||
|
||||
const stores = parseStoresSheet(storesRaw);
|
||||
const responses = parseResponsesSheet(responsesRaw);
|
||||
const merged = mergeAuditData(stores.length > 0 ? stores : INITIAL_STORES, responses.length > 0 ? responses : INITIAL_RESPONSES);
|
||||
|
||||
return {
|
||||
stores: stores.length > 0 ? stores : INITIAL_STORES,
|
||||
responses: responses.length > 0 ? responses : INITIAL_RESPONSES,
|
||||
merged,
|
||||
lastSynced: now,
|
||||
source: 'google_sheets_api'
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.warn('API v4 fetch failed, trying CSV fallback...', e);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Attempt CSV export
|
||||
try {
|
||||
const storesRaw = await fetchSheetCSV(spreadsheetId, 'tiendas');
|
||||
const responsesRaw = await fetchSheetCSV(spreadsheetId, 'Form responses');
|
||||
|
||||
const stores = parseStoresSheet(storesRaw);
|
||||
const responses = parseResponsesSheet(responsesRaw);
|
||||
|
||||
const finalStores = stores.length > 0 ? stores : INITIAL_STORES;
|
||||
const finalResponses = responses.length > 0 ? responses : INITIAL_RESPONSES;
|
||||
const merged = mergeAuditData(finalStores, finalResponses);
|
||||
|
||||
return {
|
||||
stores: finalStores,
|
||||
responses: finalResponses,
|
||||
merged,
|
||||
lastSynced: now,
|
||||
source: 'google_sheets_csv'
|
||||
};
|
||||
} catch (e: any) {
|
||||
console.warn('CSV export fetch failed, using realistic demo dataset:', e);
|
||||
}
|
||||
|
||||
// 3. Fallback to Initial Demo Data
|
||||
const merged = mergeAuditData(INITIAL_STORES, INITIAL_RESPONSES);
|
||||
return {
|
||||
stores: INITIAL_STORES,
|
||||
responses: INITIAL_RESPONSES,
|
||||
merged,
|
||||
lastSynced: now,
|
||||
source: 'fallback_demo',
|
||||
error: 'No se pudo conectar directamente con Google Sheets. Mostrando dataset precargado.'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
email?: string;
|
||||
user_metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AuthSession {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_at: number;
|
||||
token_type?: string;
|
||||
user: AuthUser;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
export interface RecoverySession {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_at: number;
|
||||
token_type?: string;
|
||||
user: AuthUser;
|
||||
}
|
||||
|
||||
interface AuthResponse {
|
||||
access_token?: string;
|
||||
refresh_token?: string;
|
||||
expires_in?: number;
|
||||
expires_at?: number;
|
||||
token_type?: string;
|
||||
user?: AuthUser;
|
||||
}
|
||||
|
||||
interface N8nAuthResponse {
|
||||
ok?: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
nextStep?: 'sign_in';
|
||||
}
|
||||
|
||||
export interface SignUpResult {
|
||||
session: AuthSession | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RecoveryUrlResult {
|
||||
session: RecoverySession | null;
|
||||
error: string;
|
||||
}
|
||||
|
||||
const SUPABASE_URL = (import.meta.env.VITE_SUPABASE_URL || '').replace(/\/$/, '');
|
||||
const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || '';
|
||||
const N8N_AUTH_WEBHOOK_URL = import.meta.env.VITE_N8N_AUTH_WEBHOOK_URL || '';
|
||||
const LOCAL_SESSION_KEY = 'lucozade-audit-auth-session';
|
||||
const TAB_SESSION_KEY = 'lucozade-audit-auth-tab-session';
|
||||
const REMEMBERED_EMAIL_KEY = 'lucozade-audit-remembered-email';
|
||||
|
||||
function ensureSupabaseConfigured(): void {
|
||||
if (!SUPABASE_URL || !SUPABASE_ANON_KEY) {
|
||||
throw new Error('Authentication is not configured. Check the Supabase environment variables.');
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAuthWorkflowConfigured(): void {
|
||||
if (!N8N_AUTH_WEBHOOK_URL) {
|
||||
throw new Error('The authentication workflow is not configured.');
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(payload: any, fallback: string): string {
|
||||
return payload?.msg || payload?.message || payload?.error_description || payload?.error || fallback;
|
||||
}
|
||||
|
||||
function getCurrentAppUrl(): string {
|
||||
const basePath = import.meta.env.BASE_URL || '/';
|
||||
return new URL(basePath, window.location.origin).toString();
|
||||
}
|
||||
|
||||
async function authRequest<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
accessToken?: string
|
||||
): Promise<T> {
|
||||
ensureSupabaseConfigured();
|
||||
|
||||
const response = await fetch(`${SUPABASE_URL}${path}`, {
|
||||
...init,
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${accessToken || SUPABASE_ANON_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers || {})
|
||||
}
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, `Authentication error (${response.status})`));
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
async function requestAuthWorkflow(
|
||||
action: 'register' | 'recover',
|
||||
fields: {
|
||||
fullName?: string;
|
||||
email: string;
|
||||
password?: string;
|
||||
}
|
||||
): Promise<N8nAuthResponse> {
|
||||
ensureAuthWorkflowConfigured();
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(() => controller.abort(), 60_000);
|
||||
const form = new URLSearchParams({
|
||||
action,
|
||||
email: fields.email.trim().toLowerCase(),
|
||||
fullName: fields.fullName?.trim() || '',
|
||||
password: fields.password || '',
|
||||
redirectUrl: getCurrentAppUrl(),
|
||||
website: ''
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(N8N_AUTH_WEBHOOK_URL, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
mode: 'cors',
|
||||
headers: {
|
||||
Accept: 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
const rawBody = await response.text();
|
||||
let payload: N8nAuthResponse = {};
|
||||
if (rawBody) {
|
||||
try {
|
||||
payload = JSON.parse(rawBody) as N8nAuthResponse;
|
||||
} catch {
|
||||
payload = { message: rawBody };
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok || payload.ok === false) {
|
||||
throw new Error(getErrorMessage(payload, `Authentication service error (${response.status})`));
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error('The authentication service took too long to respond. Please try again.');
|
||||
}
|
||||
if (error instanceof TypeError || String(error?.message || '').toLowerCase().includes('failed to fetch')) {
|
||||
throw new Error('The authentication service could not be reached. Please try again.');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSession(payload: AuthResponse, fallbackUser?: AuthUser): AuthSession | null {
|
||||
const user = payload.user || fallbackUser;
|
||||
if (!payload.access_token || !payload.refresh_token || !user) return null;
|
||||
|
||||
return {
|
||||
access_token: payload.access_token,
|
||||
refresh_token: payload.refresh_token,
|
||||
expires_at: payload.expires_at || Math.floor(Date.now() / 1000) + (payload.expires_in || 3600),
|
||||
token_type: payload.token_type,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchCurrentUser(accessToken: string): Promise<AuthUser> {
|
||||
if (!accessToken) throw new Error('The authentication token is missing.');
|
||||
return authRequest<AuthUser>('/auth/v1/user', { method: 'GET' }, accessToken);
|
||||
}
|
||||
|
||||
export async function verifyAppAccess(session: AuthSession): Promise<void> {
|
||||
ensureSupabaseConfigured();
|
||||
|
||||
const userId = encodeURIComponent(session.user.id);
|
||||
const response = await fetch(
|
||||
`${SUPABASE_URL}/rest/v1/lucozade_access?user_id=eq.${userId}&is_active=eq.true&select=user_id`,
|
||||
{
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
apikey: SUPABASE_ANON_KEY,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const payload = await response.json().catch(() => []);
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage(payload, 'Unable to verify access to this application.'));
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload) || payload.length === 0) {
|
||||
throw new Error('This account is not authorized to access this application.');
|
||||
}
|
||||
}
|
||||
|
||||
function clearStoredSessions(): void {
|
||||
localStorage.removeItem(LOCAL_SESSION_KEY);
|
||||
sessionStorage.removeItem(TAB_SESSION_KEY);
|
||||
}
|
||||
|
||||
function saveSession(session: AuthSession | null, remember = false): void {
|
||||
clearStoredSessions();
|
||||
if (!session) return;
|
||||
|
||||
const value = JSON.stringify({ ...session, remember });
|
||||
if (remember) {
|
||||
localStorage.setItem(LOCAL_SESSION_KEY, value);
|
||||
} else {
|
||||
sessionStorage.setItem(TAB_SESSION_KEY, value);
|
||||
}
|
||||
}
|
||||
|
||||
function parseStoredSession(raw: string | null, remember: boolean): AuthSession | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const session = JSON.parse(raw) as AuthSession;
|
||||
if (!session?.access_token || !session?.refresh_token || !session?.user?.id) return null;
|
||||
return { ...session, remember };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readSession(): AuthSession | null {
|
||||
const tabSession = parseStoredSession(sessionStorage.getItem(TAB_SESSION_KEY), false);
|
||||
if (tabSession) return tabSession;
|
||||
|
||||
const rememberedSession = parseStoredSession(localStorage.getItem(LOCAL_SESSION_KEY), true);
|
||||
if (rememberedSession) return rememberedSession;
|
||||
|
||||
clearStoredSessions();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getRememberedEmail(): string {
|
||||
return (localStorage.getItem(REMEMBERED_EMAIL_KEY) || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function setRememberedEmail(email: string, remember: boolean): void {
|
||||
if (remember) {
|
||||
localStorage.setItem(REMEMBERED_EMAIL_KEY, email.trim().toLowerCase());
|
||||
} else {
|
||||
localStorage.removeItem(REMEMBERED_EMAIL_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
export async function signInWithPassword(
|
||||
email: string,
|
||||
password: string,
|
||||
remember = false
|
||||
): Promise<AuthSession> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const payload = await authRequest<AuthResponse>('/auth/v1/token?grant_type=password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: normalizedEmail, password })
|
||||
});
|
||||
|
||||
const user = payload.user || await fetchCurrentUser(payload.access_token || '');
|
||||
const session = normalizeSession(payload, user);
|
||||
if (!session) throw new Error('The session could not be started.');
|
||||
|
||||
const finalSession = { ...session, remember };
|
||||
try {
|
||||
await verifyAppAccess(finalSession);
|
||||
} catch (error) {
|
||||
clearStoredSessions();
|
||||
throw error;
|
||||
}
|
||||
|
||||
setRememberedEmail(normalizedEmail, remember);
|
||||
saveSession(finalSession, remember);
|
||||
return finalSession;
|
||||
}
|
||||
|
||||
export async function signUpWithPassword(
|
||||
fullName: string,
|
||||
email: string,
|
||||
password: string
|
||||
): Promise<SignUpResult> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const workflowResult = await requestAuthWorkflow('register', {
|
||||
fullName: fullName.trim(),
|
||||
email: normalizedEmail,
|
||||
password
|
||||
});
|
||||
|
||||
// The workflow creates a confirmed Auth user and grants application access.
|
||||
// Retry briefly because Auth and PostgREST can take a moment to expose the new row.
|
||||
let lastError: unknown = null;
|
||||
for (const delay of [100, 250, 500, 900, 1500, 2500]) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, delay));
|
||||
try {
|
||||
const session = await signInWithPassword(normalizedEmail, password, false);
|
||||
return {
|
||||
session,
|
||||
message: workflowResult.message || 'Account created successfully.'
|
||||
};
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
|
||||
const detail = lastError instanceof Error ? lastError.message : '';
|
||||
return {
|
||||
session: null,
|
||||
message: workflowResult.message || detail || 'Account created successfully. Sign in with your email and password.'
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendPasswordReset(email: string): Promise<string> {
|
||||
const result = await requestAuthWorkflow('recover', {
|
||||
email: email.trim().toLowerCase()
|
||||
});
|
||||
return result.message || 'If an active account exists, a recovery link has been sent.';
|
||||
}
|
||||
|
||||
function getHashParams(): URLSearchParams {
|
||||
return new URLSearchParams(window.location.hash.replace(/^#/, ''));
|
||||
}
|
||||
|
||||
export async function getRecoverySessionFromUrl(): Promise<RecoveryUrlResult> {
|
||||
const params = getHashParams();
|
||||
const errorDescription = (params.get('error_description') || params.get('error') || '').trim();
|
||||
if (errorDescription) {
|
||||
return {
|
||||
session: null,
|
||||
error: errorDescription
|
||||
};
|
||||
}
|
||||
|
||||
const type = (params.get('type') || '').toLowerCase();
|
||||
const accessToken = (params.get('access_token') || '').trim();
|
||||
if (type !== 'recovery' || !accessToken) {
|
||||
return { session: null, error: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await fetchCurrentUser(accessToken);
|
||||
const expiresIn = Number(params.get('expires_in') || '3600');
|
||||
return {
|
||||
session: {
|
||||
access_token: accessToken,
|
||||
refresh_token: (params.get('refresh_token') || '').trim() || undefined,
|
||||
token_type: (params.get('token_type') || 'bearer').trim(),
|
||||
expires_at: Math.floor(Date.now() / 1000) + (Number.isFinite(expiresIn) ? expiresIn : 3600),
|
||||
user
|
||||
},
|
||||
error: ''
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
session: null,
|
||||
error: error?.message || 'The recovery link is invalid or has expired.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function updatePasswordFromRecovery(
|
||||
recoverySession: RecoverySession,
|
||||
password: string
|
||||
): Promise<void> {
|
||||
if (!recoverySession?.access_token) {
|
||||
throw new Error('The recovery link is invalid or has expired.');
|
||||
}
|
||||
if (recoverySession.expires_at * 1000 <= Date.now()) {
|
||||
throw new Error('The recovery link has expired. Request a new one.');
|
||||
}
|
||||
|
||||
await authRequest<AuthUser>('/auth/v1/user', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ password })
|
||||
}, recoverySession.access_token);
|
||||
|
||||
try {
|
||||
await authRequest('/auth/v1/logout', { method: 'POST' }, recoverySession.access_token);
|
||||
} catch {
|
||||
// Password was already updated; local recovery data is still cleared below.
|
||||
}
|
||||
|
||||
clearStoredSessions();
|
||||
}
|
||||
|
||||
export function clearAuthActionFromUrl(): void {
|
||||
window.history.replaceState({}, document.title, `${window.location.pathname}${window.location.search}`);
|
||||
}
|
||||
|
||||
export async function refreshSession(session: AuthSession): Promise<AuthSession | null> {
|
||||
try {
|
||||
const payload = await authRequest<AuthResponse>('/auth/v1/token?grant_type=refresh_token', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ refresh_token: session.refresh_token })
|
||||
});
|
||||
const refreshed = normalizeSession(payload, payload.user || session.user);
|
||||
if (!refreshed) {
|
||||
clearStoredSessions();
|
||||
return null;
|
||||
}
|
||||
|
||||
const finalSession = { ...refreshed, remember: Boolean(session.remember) };
|
||||
await verifyAppAccess(finalSession);
|
||||
saveSession(finalSession, Boolean(finalSession.remember));
|
||||
return finalSession;
|
||||
} catch {
|
||||
clearStoredSessions();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getStoredSession(): Promise<AuthSession | null> {
|
||||
const session = readSession();
|
||||
if (!session) return null;
|
||||
|
||||
if (session.expires_at * 1000 <= Date.now() + 60_000) {
|
||||
return refreshSession(session);
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyAppAccess(session);
|
||||
return session;
|
||||
} catch {
|
||||
clearStoredSessions();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function signOut(session: AuthSession | null): Promise<void> {
|
||||
try {
|
||||
if (session?.access_token) {
|
||||
await authRequest('/auth/v1/logout', { method: 'POST' }, session.access_token);
|
||||
}
|
||||
} catch {
|
||||
// The local session is still cleared if the network request cannot be completed.
|
||||
} finally {
|
||||
clearStoredSessions();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
export interface Store {
|
||||
customer: string; // Customer / Punto de venta (Key)
|
||||
zone: string; // Zone
|
||||
address: string; // Address
|
||||
coordinates?: string; // Coordinates (lat, long)
|
||||
customerCode: string; // Customer Code
|
||||
customerChannel: string; // Customer Channel (e.g., Supermercados, Conveniencia)
|
||||
csSoldIn?: number; // Cs Sold In
|
||||
unitsSoldIn?: number; // Units Sold In (Sell-In)
|
||||
lSoldIn?: number; // L Sold In
|
||||
auditor?: string; // Auditor #
|
||||
visitDays?: string[]; // Monday, Tuesday, etc.
|
||||
skuDetail?: string; // SKU detail
|
||||
assignedDate?: string; // Assigned date (Column 'fecha' in tiendas sheet)
|
||||
week?: string; // Week column in tiendas sheet (e.g., "Week 29", "Semana 29")
|
||||
}
|
||||
|
||||
export interface AuditFormResponse {
|
||||
submissionId: string;
|
||||
submissionDate: string; // Submission Date
|
||||
customer: string; // Dynamic Dropdowns (Punto de venta)
|
||||
isAvailable: 'Yes' | 'No' | string; // Is Lucozade Sport Ice Kick available for sale today?
|
||||
placementLocation?: string; // Where is the product located?
|
||||
shelfPhotos?: string[]; // Add photos of the shelf or display
|
||||
popVisible?: string; // Is promotional (POP) material visible and in good condition?
|
||||
popPhotos?: string[]; // Add photos of the material POP
|
||||
retailPrice?: number; // What is the exact retail price on the shelf?
|
||||
facingsIceKick?: number; // Count the facings of Lucozade Ice Kick.
|
||||
facingsLucozadeBrand?: number; // Count the facings of the entire Lucozade brand.
|
||||
facingsCategoryTotal?: number; // Count facings of entire energy and sports drink category
|
||||
categoryPhotos?: string[]; // Add photos of the Energy & Sports Drinks shelf
|
||||
skuSubstitution?: string; // Which brand/SKU gave up shelf space and lost facings
|
||||
totalPhysicalInventory?: number; // Total Physical Inventory (Lucozade Sport Ice Kick)
|
||||
lastUpdateDate?: string;
|
||||
week?: string; // Week column in Form responses sheet (e.g., "Week 29", "Semana 29")
|
||||
}
|
||||
|
||||
export interface MergedStoreAudit {
|
||||
store: Store;
|
||||
response?: AuditFormResponse;
|
||||
allResponses?: AuditFormResponse[]; // All historical responses for this store
|
||||
status: 'Visited' | 'Pending'; // Visitada / Pendiente
|
||||
hasOOS: boolean; // True if visited and isAvailable == 'No'
|
||||
shareOfBrandShelf: number; // (facingsIceKick / facingsLucozadeBrand) * 100
|
||||
shareOfCategoryShelf: number; // (facingsLucozadeBrand / facingsCategoryTotal) * 100
|
||||
}
|
||||
|
||||
export interface FilterState {
|
||||
zone: string;
|
||||
channel: string;
|
||||
customer: string;
|
||||
status: 'all' | 'Visited' | 'Pending';
|
||||
oosStatus: 'all' | 'Available' | 'OOS';
|
||||
search: string;
|
||||
selectedWeek?: string; // Selected week key ("2026-07-20_2026-07-26" or "all")
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
}
|
||||
|
||||
export interface DashboardKPIs {
|
||||
totalStores: number;
|
||||
totalResponses: number; // Total registros en Form responses
|
||||
visitedStores: number;
|
||||
pendingStores: number;
|
||||
complianceRate: number; // % Cumplimiento (visitedStores / totalStores * 100)
|
||||
oosStores: number; // Count of responses with isAvailable == 'No'
|
||||
availableStores: number; // Count of responses with isAvailable == 'Yes'
|
||||
oosRate: number; // (oosStores / totalResponses * 100)
|
||||
numericDistribution: number; // (availableStores / totalResponses * 100)
|
||||
totalPhysicalInventory: number; // Total physical inventory across visited stores
|
||||
totalUnitsSoldIn: number; // Total Sell-In across filter
|
||||
totalSellOutUnits: number; // Total volume sell-out
|
||||
totalSellOutValue: number; // Total value sell-out
|
||||
avgRetailPrice: number; // Average price on shelf
|
||||
avgIceKickFacings: number; // Average Ice Kick facings
|
||||
avgBrandShelfShare: number; // Avg Share of Brand Shelf %
|
||||
avgCategoryShelfShare: number; // Avg Share of Category Shelf %
|
||||
// Placement & Display
|
||||
mainShelfStores: number;
|
||||
secondaryDisplayStores: number;
|
||||
gondolaEndStores: number;
|
||||
additionalExhibitionStores: number;
|
||||
mainShelfRate: number;
|
||||
secondaryDisplayRate: number;
|
||||
gondolaEndRate: number;
|
||||
additionalExhibitionRate: number;
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
export interface WeekOption {
|
||||
key: string; // e.g. "2026-07-20_2026-07-26"
|
||||
label: string; // e.g. "Jul 20 - Jul 26, 2026"
|
||||
shortLabel: string; // e.g. "Jul 20 - Jul 26"
|
||||
startDate: string; // "2026-07-20"
|
||||
endDate: string; // "2026-07-26"
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse various date formats into a Javascript Date object.
|
||||
* Prioritizes YYYY-MM-DD and DD/MM/YYYY formats typical in Spanish/Google Sheets data.
|
||||
*/
|
||||
export function parseDateString(dateStr?: string): Date | null {
|
||||
if (!dateStr || !dateStr.trim()) return null;
|
||||
const str = dateStr.trim();
|
||||
|
||||
// 1. Handle Excel/Google Sheets serial date numbers (e.g. 45000 to 55000)
|
||||
const num = Number(str);
|
||||
if (!isNaN(num) && num > 30000 && num < 60000) {
|
||||
const jsTimestamp = (num - 25569) * 86400 * 1000;
|
||||
const d = new Date(jsTimestamp);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try YYYY-MM-DD or YYYY/MM/DD (ISO / Standard YMD)
|
||||
const ymdMatch = str.match(/^(\d{4})[\/-](\d{1,2})[\/-](\d{1,2})/);
|
||||
if (ymdMatch) {
|
||||
const year = parseInt(ymdMatch[1], 10);
|
||||
const month = parseInt(ymdMatch[2], 10) - 1;
|
||||
const day = parseInt(ymdMatch[3], 10);
|
||||
if (month >= 0 && month <= 11 && day >= 1 && day <= 31) {
|
||||
return new Date(year, month, day);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try DD/MM/YYYY or MM/DD/YYYY (or 2-digit years YY e.g. 20/07/26)
|
||||
const dmyMatch = str.match(/^(\d{1,2})[\/-](\d{1,2})[\/-](\d{2,4})/);
|
||||
if (dmyMatch) {
|
||||
const p1 = parseInt(dmyMatch[1], 10);
|
||||
const p2 = parseInt(dmyMatch[2], 10);
|
||||
let rawYear = parseInt(dmyMatch[3], 10);
|
||||
if (rawYear < 100) rawYear += 2000;
|
||||
|
||||
let day = p1;
|
||||
let month = p2 - 1;
|
||||
|
||||
if (p1 > 12) {
|
||||
// p1 is day (DD/MM/YYYY)
|
||||
day = p1;
|
||||
month = p2 - 1;
|
||||
} else if (p2 > 12) {
|
||||
// p2 is day (MM/DD/YYYY)
|
||||
month = p1 - 1;
|
||||
day = p2;
|
||||
} else {
|
||||
// Both <= 12: Default to DD/MM/YYYY (Spanish standard)
|
||||
day = p1;
|
||||
month = p2 - 1;
|
||||
}
|
||||
|
||||
if (month >= 0 && month <= 11 && day >= 1 && day <= 31) {
|
||||
return new Date(rawYear, month, day);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Fallback: Standard Date.parse
|
||||
const timestamp = Date.parse(str);
|
||||
if (!isNaN(timestamp)) {
|
||||
const d = new Date(timestamp);
|
||||
if (!isNaN(d.getTime())) {
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Monday-to-Sunday week range for a given date.
|
||||
*/
|
||||
export function getWeekRangeFromDate(dateInput: Date | string): WeekOption | null {
|
||||
const date = typeof dateInput === 'string' ? parseDateString(dateInput) : dateInput;
|
||||
if (!date || isNaN(date.getTime())) return null;
|
||||
|
||||
const day = date.getDay(); // 0 = Sunday, 1 = Monday...
|
||||
const diffToMonday = day === 0 ? -6 : 1 - day;
|
||||
|
||||
const monday = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
monday.setDate(date.getDate() + diffToMonday);
|
||||
|
||||
const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate());
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
|
||||
const pad = (n: number) => (n < 10 ? '0' + n : '' + n);
|
||||
const startDate = `${monday.getFullYear()}-${pad(monday.getMonth() + 1)}-${pad(monday.getDate())}`;
|
||||
const endDate = `${sunday.getFullYear()}-${pad(sunday.getMonth() + 1)}-${pad(sunday.getDate())}`;
|
||||
|
||||
const formatMonthDay = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
const year = monday.getFullYear();
|
||||
|
||||
const key = `${startDate}_${endDate}`;
|
||||
const label = `${formatMonthDay(monday)} - ${formatMonthDay(sunday)}, ${year}`;
|
||||
const shortLabel = `${formatMonthDay(monday)} - ${formatMonthDay(sunday)}`;
|
||||
|
||||
return { key, label, shortLabel, startDate, endDate };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date string falls within a specific week key ("2026-07-20_2026-07-26").
|
||||
*/
|
||||
export function isDateInWeek(dateStr?: string, weekKey?: string): boolean {
|
||||
if (!dateStr || !weekKey || weekKey === 'all') return true;
|
||||
const range = getWeekRangeFromDate(dateStr);
|
||||
if (!range) return false;
|
||||
return range.key === weekKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize any week representation (e.g., "30", "30.0", "Semana 30", "semana 30", "Week 30", "W30")
|
||||
* to a standardized display string "Semana X" (e.g. "Semana 30").
|
||||
*/
|
||||
export function normalizeWeek(weekStr?: string): string {
|
||||
if (!weekStr) return '';
|
||||
const str = weekStr.trim();
|
||||
if (!str) return '';
|
||||
|
||||
const match = str.match(/\d+/);
|
||||
if (match) {
|
||||
const num = parseInt(match[0], 10);
|
||||
if (!isNaN(num) && num > 0 && num < 100) {
|
||||
return `Semana ${num}`;
|
||||
}
|
||||
}
|
||||
|
||||
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate ISO week number from a Date or date string.
|
||||
*/
|
||||
export function getWeekNumberFromDate(dateInput: Date | string): number | null {
|
||||
const d = typeof dateInput === 'string' ? parseDateString(dateInput) : dateInput;
|
||||
if (!d || isNaN(d.getTime())) return null;
|
||||
|
||||
const target = new Date(d.valueOf());
|
||||
const dayNr = (d.getDay() + 6) % 7;
|
||||
target.setDate(target.getDate() - dayNr + 3);
|
||||
const firstThursday = target.valueOf();
|
||||
target.setMonth(0, 1);
|
||||
if (target.getDay() !== 4) {
|
||||
target.setMonth(0, 1 + ((4 - target.getDay() + 7) % 7));
|
||||
}
|
||||
return 1 + Math.ceil((firstThursday - target.valueOf()) / 604800000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item (store or response) matches the selected week filter.
|
||||
* Matches explicit week column value or falls back to date range / ISO week check.
|
||||
*/
|
||||
export function isItemInWeek(weekVal?: string, dateVal?: string, selectedWeek?: string): boolean {
|
||||
if (!selectedWeek || selectedWeek === 'all') return true;
|
||||
|
||||
const normSelected = normalizeWeek(selectedWeek);
|
||||
const normWeek = normalizeWeek(weekVal);
|
||||
|
||||
if (normWeek && normSelected) {
|
||||
if (normWeek.toLowerCase() === normSelected.toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (dateVal) {
|
||||
if (isDateInWeek(dateVal, selectedWeek)) return true;
|
||||
|
||||
const weekNumFromDate = getWeekNumberFromDate(dateVal);
|
||||
if (weekNumFromDate && normSelected.toLowerCase() === `semana ${weekNumFromDate}`) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all unique week options from stores and form responses.
|
||||
* Uses explicit 'week' column values if available, or falls back to Monday-Sunday date ranges.
|
||||
*/
|
||||
export function extractWeekOptions(
|
||||
stores: Array<{ assignedDate?: string; week?: string }>,
|
||||
responses: Array<{ submissionDate?: string; week?: string }>
|
||||
): WeekOption[] {
|
||||
const explicitWeeks = new Set<string>();
|
||||
|
||||
stores.forEach(s => {
|
||||
const w = normalizeWeek(s.week);
|
||||
if (w) explicitWeeks.add(w);
|
||||
});
|
||||
|
||||
responses.forEach(r => {
|
||||
const w = normalizeWeek(r.week);
|
||||
if (w) explicitWeeks.add(w);
|
||||
});
|
||||
|
||||
if (explicitWeeks.size > 0) {
|
||||
const sorted = Array.from(explicitWeeks).sort((a, b) => {
|
||||
const numA = (a.match(/\d+/) || [])[0] ? parseInt((a.match(/\d+/) || [])[0], 10) : 0;
|
||||
const numB = (b.match(/\d+/) || [])[0] ? parseInt((b.match(/\d+/) || [])[0], 10) : 0;
|
||||
if (numA && numB) return numB - numA;
|
||||
return b.localeCompare(a, undefined, { numeric: true });
|
||||
});
|
||||
|
||||
return sorted.map(w => ({
|
||||
key: w,
|
||||
label: w,
|
||||
shortLabel: w,
|
||||
startDate: w,
|
||||
endDate: w
|
||||
}));
|
||||
}
|
||||
|
||||
// Fallback: derive weeks from dates
|
||||
const map = new Map<string, WeekOption>();
|
||||
|
||||
stores.forEach(s => {
|
||||
if (s.assignedDate) {
|
||||
const option = getWeekRangeFromDate(s.assignedDate);
|
||||
if (option && !map.has(option.key)) {
|
||||
map.set(option.key, option);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
responses.forEach(r => {
|
||||
if (r.submissionDate) {
|
||||
const option = getWeekRangeFromDate(r.submissionDate);
|
||||
if (option && !map.has(option.key)) {
|
||||
map.set(option.key, option);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sort weeks descending (latest week first)
|
||||
return Array.from(map.values()).sort((a, b) => b.startDate.localeCompare(a.startDate));
|
||||
}
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly BASE_URL: string;
|
||||
readonly VITE_SUPABASE_URL: string;
|
||||
readonly VITE_SUPABASE_ANON_KEY: string;
|
||||
readonly VITE_N8N_AUTH_WEBHOOK_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user