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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user