Initial production-ready Lucozade audit dashboard

This commit is contained in:
2026-08-06 08:03:32 -04:00
commit edd0af342e
33 changed files with 9209 additions and 0 deletions
+608
View File
@@ -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>
);
}