diff --git a/inventory-server/auth/routes.js b/inventory-server/auth/routes.js index 1783e52..664d81f 100644 --- a/inventory-server/auth/routes.js +++ b/inventory-server/auth/routes.js @@ -12,13 +12,20 @@ export function createAuthRoutes({ pool }) { // shared/auth/middleware.js and is used by downstream services. Auth-server's surface is // small enough that a local copy is fine; the security boundary is the JWT verify step. async function authenticate(req, res, next) { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ error: 'Authentication required' }); + } + const token = authHeader.split(' ')[1]; + let decoded; + try { + decoded = jwt.verify(token, process.env.JWT_SECRET); + } catch (error) { + return res.status(401).json({ error: 'Invalid token' }); + } + // DB failures are 500, not 401 — the frontend ends the session on 401, + // and a transient outage must not log users out. try { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ error: 'Authentication required' }); - } - const token = authHeader.split(' ')[1]; - const decoded = jwt.verify(token, process.env.JWT_SECRET); const result = await pool.query( 'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1', [decoded.userId] @@ -29,7 +36,8 @@ export function createAuthRoutes({ pool }) { req.user = result.rows[0]; next(); } catch (error) { - res.status(401).json({ error: 'Invalid token' }); + console.error('Auth DB error:', error); + res.status(500).json({ error: 'Server error' }); } } @@ -58,7 +66,7 @@ export function createAuthRoutes({ pool }) { const token = jwt.sign( { userId: user.id, username: user.username }, process.env.JWT_SECRET, - { expiresIn: '8h' } + { expiresIn: '7d' } ); const permissions = await getUserPermissions(user.id); res.json({ diff --git a/inventory-server/auth/server.js b/inventory-server/auth/server.js index 82b26e1..09139ce 100644 --- a/inventory-server/auth/server.js +++ b/inventory-server/auth/server.js @@ -39,6 +39,11 @@ logger.info({ const app = express(); const port = Number(process.env.AUTH_PORT) || 3011; +// Behind Caddy on localhost: without this, req.ip is ::1 for every request and +// the rate limiters put ALL users in one shared bucket (10 logins/15min for +// the whole office). Same setting as inventory-server's server.js. +app.set('trust proxy', 'loopback'); + const pool = new Pool({ host: process.env.DB_HOST, user: process.env.DB_USER, diff --git a/inventory-server/scripts/calculate-metrics-new.js b/inventory-server/scripts/calculate-metrics-new.js index 80d42fc..b9a2a1c 100644 --- a/inventory-server/scripts/calculate-metrics-new.js +++ b/inventory-server/scripts/calculate-metrics-new.js @@ -314,7 +314,6 @@ async function syncSettingsProductTable() { lead_time_days, days_of_stock, safety_stock, - forecast_method, exclude_from_forecast ) SELECT @@ -322,7 +321,6 @@ async function syncSettingsProductTable() { CAST(NULL AS INTEGER), CAST(NULL AS INTEGER), COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0), - CAST(NULL AS VARCHAR), FALSE FROM public.products p diff --git a/inventory-server/src/routes/config.js b/inventory-server/src/routes/config.js index 6933e14..816ddf2 100644 --- a/inventory-server/src/routes/config.js +++ b/inventory-server/src/routes/config.js @@ -136,7 +136,7 @@ router.put('/products/:pid', async (req, res) => { const pool = req.app.locals.pool; try { const { pid } = req.params; - const { lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast } = req.body; + const { lead_time_days, days_of_stock, safety_stock, exclude_from_forecast } = req.body; console.log(`[Config Route] Updating product settings for ${pid}:`, req.body); @@ -149,10 +149,10 @@ router.put('/products/:pid', async (req, res) => { if (checkProduct.length === 0) { // Insert if it doesn't exist await pool.query( - `INSERT INTO settings_product - (pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast) - VALUES ($1, $2, $3, $4, $5, $6)`, - [pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] + `INSERT INTO settings_product + (pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast) + VALUES ($1, $2, $3, $4, $5)`, + [pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast] ); } else { // Update if it exists @@ -161,11 +161,10 @@ router.put('/products/:pid', async (req, res) => { SET lead_time_days = $2, days_of_stock = $3, safety_stock = $4, - forecast_method = $5, - exclude_from_forecast = $6, + exclude_from_forecast = $5, updated_at = CURRENT_TIMESTAMP WHERE pid::text = $1`, - [pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] + [pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast] ); } @@ -190,7 +189,6 @@ router.post('/products/:pid/reset', async (req, res) => { SET lead_time_days = NULL, days_of_stock = NULL, safety_stock = 0, - forecast_method = NULL, exclude_from_forecast = false, updated_at = CURRENT_TIMESTAMP WHERE pid::text = $1`, diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index e231c91..6c8a082 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -36,6 +36,7 @@ const RepeatOrders = lazy(() => import('./pages/RepeatOrders')); // 2. Dashboard app - separate chunk const Dashboard = lazy(() => import('./pages/Dashboard')); const SmallDashboard = lazy(() => import('./pages/SmallDashboard')); +const MobileDashboard = lazy(() => import('./pages/MobileDashboard')); // 3. Product import - separate chunk const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import }))); @@ -69,27 +70,26 @@ function App() { }, }); - if (!response.ok) { + if (response.status === 401 || response.status === 403) { + // Token genuinely rejected — clear it and go to login localStorage.removeItem('token'); sessionStorage.removeItem('isLoggedIn'); - + // Only navigate to login if we're not already there if (!location.pathname.includes('/login')) { navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`); } - } else { + } else if (response.ok) { // If token is valid, set the login flag sessionStorage.setItem('isLoggedIn', 'true'); } + // Other statuses (500/429/etc.) are transient server issues — + // keep the token; AuthContext will retry on the next check. } catch (error) { + // Network error (server down, offline) — keep the token and let a + // later check decide. Logging out here would end valid sessions on + // every connection blip. console.error('Token verification failed:', error); - localStorage.removeItem('token'); - sessionStorage.removeItem('isLoggedIn'); - - // Only navigate to login if we're not already there - if (!location.pathname.includes('/login')) { - navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`); - } } } }; @@ -109,6 +109,11 @@ function App() { } /> + }> + + + } /> diff --git a/inventory/src/components/dashboard/Header.jsx b/inventory/src/components/dashboard/Header.jsx index 5f78a80..6172aee 100644 --- a/inventory/src/components/dashboard/Header.jsx +++ b/inventory/src/components/dashboard/Header.jsx @@ -1,4 +1,5 @@ import React from "react"; +import { useNow } from "@/hooks/useNow"; const formatDate = (date) => date.toLocaleDateString("en-US", { @@ -15,7 +16,7 @@ const greeting = (date) => { }; const Header = ({ right = null }) => { - const now = new Date(); + const now = useNow(); return (
diff --git a/inventory/src/components/dashboard/mobile/StudioMobile.tsx b/inventory/src/components/dashboard/mobile/StudioMobile.tsx new file mode 100644 index 0000000..e0d3439 --- /dev/null +++ b/inventory/src/components/dashboard/mobile/StudioMobile.tsx @@ -0,0 +1,1071 @@ +/** + * StudioMobile — the /mobile dashboard, Sorbet Studio style. + * + * One scrolling page tuned to a phone viewport (~420px). Bento-style hero with + * a featured revenue tile, then light-chrome cards. Charts and lists here are + * mobile-specific simplifications — plain SVG lines instead of recharts, no + * axes chrome, no tables — because the full dashboard components don't survive + * being squeezed to phone width. Data hooks mirror NightboardSmall/StatCards + * one-to-one; only the presentation is new. Deliberately lighter than the full + * dashboard and a little richer than the kiosk: scroll buys some depth, but + * every section should still read at a glance. + */ +import React from "react"; +import { useQuery } from "@tanstack/react-query"; +import { acotService } from "@/services/dashboard/acotService"; +import { apiClient } from "@/utils/apiClient"; +import { apiFetch } from "@/utils/api"; +import config from "@/config"; +import { STUDIO_TILES } from "@/lib/dashboard/designTokens"; +import { useNow } from "@/hooks/useNow"; +// @ts-expect-error - JSX module without type declarations +import { processBasicData } from "@/components/dashboard/RealtimeAnalytics"; +// @ts-expect-error - JSX module without type declarations +import { EventDialog } from "@/components/dashboard/EventFeed.jsx"; + +// ── palette (Sorbet Studio, hex-pinned like the tokens file) ───────────────── +const C = { + ground: "#f8f7f5", + ink: "#2b2925", + muted: "#7c7870", + faint: "#a09b92", + border: "#e7e5e1", + hairline: "#f0eeea", + coral: "#e06a4e", + teal: "#0f8f77", + up: "#237a4d", + down: "#b3503f", +} as const; + +// ── formatters (same conventions as NightboardSmall) ───────────────────────── +const fmtMoney = (v: number | null | undefined) => + v == null || isNaN(v) + ? "—" + : new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 0, + }).format(v); + +const fmtCompact = (v: number | null | undefined) => { + if (v == null || isNaN(v)) return "—"; + if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`; + if (Math.abs(v) >= 1_000) return `$${(v / 1_000).toFixed(0)}K`; + return `$${Math.round(v)}`; +}; + +const fmtCount = (v: number | null | undefined) => { + if (v == null || isNaN(v)) return "—"; + if (v >= 10_000) return `${(v / 1_000).toFixed(1)}K`; + return v.toLocaleString(); +}; + +const trendPct = (cur: number, prev: number) => + prev > 0 ? ((cur - prev) / prev) * 100 : null; + +const fmtTime = (iso: string | undefined) => + iso + ? new Date(iso).toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }) + : ""; + +const hourLabel = (h: number) => `${h % 12 || 12} ${h < 12 ? "AM" : "PM"}`; + +// ── metric ids for the event feed (same Klaviyo metrics as EventFeed) ──────── +const METRIC_IDS = { + PLACED_ORDER: "Y8cqcF", + SHIPPED_ORDER: "VExpdL", + ACCOUNT_CREATED: "TeeypV", + CANCELED_ORDER: "YjVMNg", + NEW_BLOG_POST: "YcxeDr", + PAYMENT_REFUNDED: "R7XUYh", +} as const; + +const EVENT_META: Record = { + [METRIC_IDS.PLACED_ORDER]: { label: "Order", dot: C.up }, + [METRIC_IDS.SHIPPED_ORDER]: { label: "Shipped", dot: "#3f7fae" }, + [METRIC_IDS.ACCOUNT_CREATED]: { label: "Account", dot: "#6b5fc7" }, + [METRIC_IDS.CANCELED_ORDER]: { label: "Canceled", dot: C.down }, + [METRIC_IDS.PAYMENT_REFUNDED]: { label: "Refund", dot: "#c9822a" }, + [METRIC_IDS.NEW_BLOG_POST]: { label: "Blog", dot: C.muted }, +}; + +const fmtEventMoney = (amount: string | number | undefined) => { + const num = typeof amount === "string" ? parseFloat(amount) : amount; + if (num == null || isNaN(num)) return ""; + return `${num < 0 ? "-" : ""}$${Math.abs(num).toFixed(2)}`; +}; + +const shipMethod = (method: string | undefined) => { + if (!method) return "Digital"; + if (method.includes("usps")) return "USPS"; + if (method.includes("fedex")) return "FedEx"; + if (method.includes("ups")) return "UPS"; + return "Standard"; +}; + +// ── response shapes (acotService is untyped JS; mirrors what the API returns) ─ +interface EventProps { + ShippingName?: string; + OrderId?: string | number; + TotalAmount?: string | number; + ShipMethod?: string; + FirstName?: string; + LastName?: string; + EmailAddress?: string; + CancelReason?: string; + FromOrder?: string | number; + PaymentAmount?: string | number; + PaymentName?: string; + title?: string; + description?: string; +} + +interface FeedEvent { + id: string; + metric_id: string; + datetime?: string; + attributes?: { datetime?: string; event_properties?: EventProps }; + event_properties: EventProps; +} + +interface TypeSlice { + count?: number; +} + +interface TodayStats { + revenue: number; + orderCount: number; + itemCount: number; + averageOrderValue: number; + averageItemsPerOrder: number; + periodProgress: number; + prevPeriodRevenue: number; + prevPeriodOrders: number; + prevPeriodAOV: number; + projectedRevenue?: number; + orderTypes?: { preOrders?: TypeSlice; localPickup?: TypeSlice; heldItems?: TypeSlice }; + shipping?: { shippedCount?: number; locations?: { total?: number } }; + refunds?: { total?: number; count?: number }; + canceledOrders?: { total?: number; count?: number }; +} + +interface Projection { + projectedRevenue?: number; + projectedOrders?: number; +} + +interface DailyRow { + date?: string; + timestamp?: string; + revenue?: number; + orders?: number; + prevRevenue?: number; + prevOrders?: number; + periodProgress?: number; +} + +interface FinancialsResponse { + totals?: Record; + previousTotals?: Record; +} + +interface TopProduct { + id: number | string; + name: string; + ImgThumb?: string; + totalQuantity: number; + orderCount: number; + totalRevenue: number; +} + +// ── tiny presentational atoms ──────────────────────────────────────────────── +// Beyond a few hundred percent the number is an artifact (day-boundary +// projections, near-zero baselines) — on a glance page, show nothing. +const trendVisible = (pct: number | null | undefined): pct is number => + pct != null && isFinite(pct) && Math.abs(pct) >= 0.1 && Math.abs(pct) < 500; + +const TrendChip = ({ + pct, + invert = false, + big = false, +}: { + pct: number | null | undefined; + invert?: boolean; + big?: boolean; +}) => { + if (!trendVisible(pct)) return null; + const good = invert ? pct < 0 : pct > 0; + return ( + + {pct > 0 ? "▲" : "▼"} {Math.abs(pct).toFixed(1)}% + + ); +}; + +/** Percentage-point delta (for margin) — same voice as TrendChip. */ +const PpChip = ({ delta }: { delta: number | null | undefined }) => { + if (delta == null || !isFinite(delta) || Math.abs(delta) < 0.1) return null; + return ( + 0 ? C.up : C.down }}> + {delta > 0 ? "▲" : "▼"} {Math.abs(delta).toFixed(1)}pp + + ); +}; + +/** Uppercase eyebrow above a bare strip — the page's only section chrome. */ +const Eyebrow = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); + +/** White section card with a slim bordered header — chrome kept, weight shed. */ +const SectionCard = ({ + title, + aside, + children, + className = "", +}: { + title: string; + aside?: React.ReactNode; + children: React.ReactNode; + className?: string; +}) => ( +
+
+ {title} + {aside} +
+ {children} +
+); + +const QuietCell = ({ + label, + value, + sub, +}: { + label: string; + value: React.ReactNode; + sub?: React.ReactNode; +}) => ( +
+
+ {label} +
+
{value}
+ {sub != null && ( +
+ {sub} +
+ )} +
+); + +/** gap-px strip of QuietCells on a hairline ground */ +const QuietStrip = ({ cols, children }: { cols: 2 | 3; children: React.ReactNode }) => ( +
+ {children} +
+); + +/** + * Mobile chart: a plain SVG polyline with a soft area fill and an emphasized + * endpoint — no recharts, no axes chrome. Optional dashed previous-period + * series shares the same scale (one axis, one story). + */ +const MobileLine = ({ + values, + prevValues, + height, + stroke, + fill, + prevStroke, + startLabel, + endLabel, + labelColor, + baseline, +}: { + values: number[]; + prevValues?: number[]; + height: number; + stroke: string; + fill: string; + prevStroke?: string; + startLabel?: string; + endLabel?: string; + labelColor: string; + baseline: string; +}) => { + if (values.length < 2) return
; + const W = 360; + const labelSpace = startLabel || endLabel ? 14 : 0; + const H = height - labelSpace; + const max = Math.max(...values, ...(prevValues || []), 1); + const toXY = (vals: number[]) => { + const step = vals.length > 1 ? W / (vals.length - 1) : W; + return vals.map((v, i) => ({ + x: i * step, + y: H - 3 - (v / max) * (H - 6), + })); + }; + const cur = toXY(values); + const prev = prevValues && prevValues.length > 1 ? toXY(prevValues) : null; + const curStr = cur.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); + const prevStr = prev?.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(" "); + const last = cur[cur.length - 1]; + return ( + + ); +}; + +// ── main component ─────────────────────────────────────────────────────────── +const StudioMobile = () => { + const now = useNow(); + + // Today stats (same source as StatCards) + const { data: stats, dataUpdatedAt } = useQuery({ + queryKey: ["mobile-stats-today"], + queryFn: async () => { + const res = (await acotService.getStats({ timeRange: "today" })) as { stats: TodayStats }; + return res.stats; + }, + refetchInterval: 60_000, + }); + + const { data: projection } = useQuery({ + queryKey: ["mobile-projection-today"], + queryFn: async () => + (await acotService.getProjection({ timeRange: "today" })) as Projection, + enabled: stats != null && stats.periodProgress < 100, + refetchInterval: 60_000, + }); + + // Today financials for the profit tile (same source as the StatCards hero) + const { data: financialsToday } = useQuery({ + queryKey: ["mobile-financials-today"], + queryFn: async () => + (await acotService.getFinancials({ timeRange: "today" })) as FinancialsResponse, + refetchInterval: 120_000, + }); + + // Ops for the shipped tile + const { data: opsData } = useQuery({ + queryKey: ["mobile-ops-today"], + queryFn: async () => + // @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type + (await acotService.getOperationsMetrics({ timeRange: "today" })) as { + totals?: { ordersShipped?: number; piecesPicked?: number }; + }, + refetchInterval: 120_000, + }); + + const { data: realtime } = useQuery({ + queryKey: ["mobile-realtime-users"], + queryFn: async () => { + const response = await apiFetch("/api/dashboard-analytics/realtime/basic", { + credentials: "include", + }); + if (!response.ok) throw new Error("Failed to fetch realtime"); + const result = await response.json(); + return processBasicData(result.data) as { last30MinUsers: number; last5MinUsers: number }; + }, + refetchInterval: 30_000, + }); + + // Event feed (same source as EventFeed) + const { data: events = [] } = useQuery({ + queryKey: ["mobile-event-feed"], + queryFn: async () => { + const response = await apiClient.get("/api/klaviyo/events/feed", { + params: { + timeRange: "today", + metricIds: JSON.stringify(Object.values(METRIC_IDS)), + }, + }); + return (response.data.data || []).map((event: FeedEvent) => ({ + ...event, + datetime: event.attributes?.datetime || event.datetime, + event_properties: event.attributes?.event_properties || {}, + })); + }, + refetchInterval: 30_000, + }); + + // Top sellers (same source as ProductGrid) + const { data: products = [] } = useQuery({ + queryKey: ["mobile-top-products"], + queryFn: async () => { + const res = (await acotService.getProducts({ timeRange: "today" })) as { + stats?: { products?: { list?: TopProduct[] } }; + }; + return res.stats?.products?.list || []; + }, + refetchInterval: 300_000, + }); + + // 30-day dailies (chart + totals), financials, forecast + const { data: daily30 = [] } = useQuery({ + queryKey: ["mobile-daily-30d"], + queryFn: async () => { + const res = (await acotService.getStatsDetails({ + timeRange: "last30days", + metric: "revenue", + daily: true, + })) as { stats?: DailyRow[] }; + return Array.isArray(res.stats) ? res.stats : []; + }, + refetchInterval: 300_000, + }); + + const { data: financials30 } = useQuery({ + queryKey: ["mobile-financials-30d"], + queryFn: async () => + (await acotService.getFinancials({ timeRange: "last30days" })) as FinancialsResponse, + refetchInterval: 300_000, + }); + + // Inventory band + const { data: stockData } = useQuery({ + queryKey: ["mobile-stock-metrics"], + queryFn: async () => { + const response = await apiFetch(`${config.apiUrl}/dashboard/stock/metrics`); + if (!response.ok) throw new Error("Failed to fetch stock metrics"); + return response.json(); + }, + refetchInterval: 600_000, + }); + + const { data: replenishData } = useQuery({ + queryKey: ["mobile-replenish-metrics"], + queryFn: async () => { + const response = await apiFetch(`${config.apiUrl}/dashboard/replenishment/metrics`); + if (!response.ok) throw new Error("Failed to fetch replenishment"); + return response.json(); + }, + refetchInterval: 600_000, + }); + + const { data: overstockData } = useQuery({ + queryKey: ["mobile-overstock-metrics"], + queryFn: async () => { + const response = await apiFetch(`${config.apiUrl}/dashboard/overstock/metrics`); + if (!response.ok) throw new Error("Failed to fetch overstock"); + return response.json(); + }, + refetchInterval: 600_000, + }); + + // ── derived values ───────────────────────────────────────────────────────── + const inProgress = stats != null && stats.periodProgress < 100; + // Minutes into the business day a projection multiplies noise ("▲ 9,914%"). + // Glanceability beats completeness here: hide projection-based chips until + // enough of the day exists to project from. + const projReliable = stats != null && stats.periodProgress >= 10; + const projRevenue = projection?.projectedRevenue ?? stats?.projectedRevenue ?? null; + const revTrend = + stats && projReliable && stats.prevPeriodRevenue > 0 + ? trendPct( + inProgress ? projRevenue ?? stats.revenue : stats.revenue, + stats.prevPeriodRevenue + ) + : null; + const projOrders = + projection?.projectedOrders ?? + (stats && stats.periodProgress > 0 + ? Math.round(stats.orderCount / (stats.periodProgress / 100)) + : null); + const ordersTrend = + stats && projReliable && stats.prevPeriodOrders > 0 + ? trendPct( + inProgress ? projOrders ?? stats.orderCount : stats.orderCount, + stats.prevPeriodOrders + ) + : null; + const aovTrend = + stats && stats.prevPeriodAOV > 0 + ? trendPct(stats.averageOrderValue, stats.prevPeriodAOV) + : null; + + const profit = financialsToday?.totals?.profit ?? null; + const marginToday = financialsToday?.totals?.margin ?? null; + // mid-period the raw profit always trails yesterday; scale by projected + // revenue the same way the StatCards hero does + const profitTrend = (() => { + const prev = financialsToday?.previousTotals?.profit; + if (!projReliable || profit == null || prev == null || prev <= 0) return null; + const scaled = + inProgress && stats && stats.revenue > 0 && projRevenue + ? profit * (projRevenue / stats.revenue) + : profit; + return trendPct(scaled, prev); + })(); + + const ops = opsData?.totals; + + // Hourly revenue shape for the hero curve, bucketed from today's placed-order + // feed events (the stats/details endpoint only returns one daily row for + // "today", and the feed is uncapped — every order today is already here). + // Sums won't exactly reconcile with stats.revenue (cancels/refunds); the + // curve is the day's shape, not a ledger. + const hourlyCurve = React.useMemo(() => { + const totals = new Array(24).fill(0) as number[]; + let any = false; + for (const e of events) { + if (e.metric_id !== METRIC_IDS.PLACED_ORDER || !e.datetime) continue; + const amt = parseFloat(String(e.event_properties?.TotalAmount ?? "")); + if (!Number.isFinite(amt)) continue; + totals[new Date(e.datetime).getHours()] += amt; + any = true; + } + if (!any) return null; + // business day runs 1 AM → 1 AM (see shared/business-time) + const nowH = now.getHours(); + const values: number[] = []; + let h = 1; + for (let i = 0; i < 24; i++) { + values.push(totals[h]); + if (h === nowH) break; + h = (h + 1) % 24; + } + return { values, start: hourLabel(1), end: hourLabel(nowH) }; + }, [events, now]); + + const daily30Rev = daily30.map((r) => Number(r.revenue) || 0); + const daily30Prev = daily30.map((r) => Number(r.prevRevenue) || 0); + const sum30 = daily30.reduce<{ + revenue: number; + prevRevenue: number; + orders: number; + prevOrders: number; + }>( + (acc, d) => ({ + revenue: acc.revenue + (Number(d.revenue) || 0), + prevRevenue: acc.prevRevenue + (Number(d.prevRevenue) || 0), + orders: acc.orders + (Number(d.orders) || 0), + prevOrders: acc.prevOrders + (Number(d.prevOrders) || 0), + }), + { revenue: 0, prevRevenue: 0, orders: 0, prevOrders: 0 } + ); + const avgPerDay = daily30.length ? sum30.revenue / daily30.length : null; + const rev30Trend = daily30.length ? trendPct(sum30.revenue, sum30.prevRevenue) : null; + const orders30Trend = daily30.length ? trendPct(sum30.orders, sum30.prevOrders) : null; + const prevAvgPerDay = daily30.length ? sum30.prevRevenue / daily30.length : null; + const avg30Trend = + avgPerDay != null && prevAvgPerDay != null ? trendPct(avgPerDay, prevAvgPerDay) : null; + + // margin with the same income-based fallback the nightboard band uses + const marginFrom = (t: Record | undefined) => { + if (!t) return null; + if (Number.isFinite(t.margin)) return t.margin; + const income = (t.grossSales || 0) - (t.refunds || 0) - (t.discounts || 0) + (t.shippingFees || 0); + return income > 0 ? ((income - (t.cogs || 0)) / income) * 100 : 0; + }; + const margin30 = marginFrom(financials30?.totals); + const prevMargin30 = marginFrom(financials30?.previousTotals); + const margin30Delta = + margin30 != null && prevMargin30 != null ? margin30 - prevMargin30 : null; + + const hour = now.getHours(); + const greeting = hour < 12 ? "Good morning" : hour < 17 ? "Good afternoon" : "Good evening"; + const dateLine = now.toLocaleDateString("en-US", { + weekday: "long", + month: "long", + day: "numeric", + }); + + const tiles = STUDIO_TILES; + const rankTones = [tiles.peach, tiles.mint, tiles.lemon, tiles.lilac]; + + return ( +
+
+ {/* header — actives live in the hero's realtime tile, so no chip here */} +
+
{greeting}
+
+ {dateLine} +
+
+ + {/* hero — bento: featured revenue + pastel tiles */} +
+
+
+ Revenue · today +
+
+ + {fmtMoney(stats?.revenue)} + + +
+ {stats != null && ( +
+ {inProgress && projReliable && projRevenue != null + ? `proj ${fmtCompact(projRevenue)} · ` + : ""} + {stats.prevPeriodRevenue > 0 ? `prev ${fmtCompact(stats.prevPeriodRevenue)}` : ""} +
+ )} + {hourlyCurve != null && hourlyCurve.values.length >= 2 && ( +
+ +
+ )} +
+ +
+
+ Orders +
+
+ {stats ? fmtCount(stats.orderCount) : "—"} +
+
+ + + {stats + ? `${trendVisible(ordersTrend) ? "· " : ""}${fmtCount(stats.itemCount)} items` + : "…"} + +
+
+ +
+
+ Gross profit +
+
+ {profit != null ? fmtMoney(profit) : "—"} +
+
+ + + {marginToday != null && Number.isFinite(marginToday) + ? `${trendVisible(profitTrend) ? "· " : ""}${marginToday.toFixed(1)}% margin` + : "…"} + +
+
+ +
+
+ AOV +
+
+ {stats?.averageOrderValue != null + ? `$${stats.averageOrderValue.toFixed(2)}` + : "—"} +
+
+ + + {stats?.averageItemsPerOrder != null + ? `${trendVisible(aovTrend) ? "· " : ""}${stats.averageItemsPerOrder.toFixed(1)} items/order` + : "…"} + +
+
+ +
+
+ Active now +
+
+ + {realtime ? fmtCount(realtime.last5MinUsers) : "—"} +
+
+ {realtime ? `${fmtCount(realtime.last30MinUsers)} last 30 min` : "…"} +
+
+
+ + {/* order flow strip */} + Order flow · today + + + + + + + + + + {/* live feed */} + + today + + } + > + {events.length === 0 ? ( +
+ Nothing yet today +
+ ) : ( +
+ {events.slice(0, 40).map((event) => { + const meta = EVENT_META[event.metric_id] || { label: "Event", dot: C.muted }; + const d = event.event_properties || {}; + let name: string | undefined; + let detail: React.ReactNode = null; + if (event.metric_id === METRIC_IDS.PLACED_ORDER) { + name = d.ShippingName; + detail = <>#{d.OrderId} · {fmtEventMoney(d.TotalAmount)}; + } else if (event.metric_id === METRIC_IDS.SHIPPED_ORDER) { + name = d.ShippingName; + detail = <>#{d.OrderId} · {shipMethod(d.ShipMethod)}; + } else if (event.metric_id === METRIC_IDS.ACCOUNT_CREATED) { + name = [d.FirstName, d.LastName].filter(Boolean).join(" ") || d.EmailAddress; + detail = d.EmailAddress; + } else if (event.metric_id === METRIC_IDS.CANCELED_ORDER) { + name = d.ShippingName; + detail = ( + <> + #{d.OrderId} · {fmtEventMoney(d.TotalAmount)} + {d.CancelReason ? ` · ${d.CancelReason}` : ""} + + ); + } else if (event.metric_id === METRIC_IDS.PAYMENT_REFUNDED) { + name = d.ShippingName; + detail = <>#{d.FromOrder} · {fmtEventMoney(d.PaymentAmount)}; + } else if (event.metric_id === METRIC_IDS.NEW_BLOG_POST) { + name = d.title; + detail = d.description; + } + return ( + +
+ + + + {name || meta.label} + + + {meta.label} + {detail ? <> · {detail} : null} + + + + {fmtTime(event.datetime)} + +
+
+ ); + })} +
+ )} +
+ + {/* top sellers */} + + today + + } + > + {products.length === 0 ? ( +
+ No sales yet today +
+ ) : ( +
+ {[...products] + .sort((a, b) => (b.totalRevenue || 0) - (a.totalRevenue || 0)) + .slice(0, 25) + .map((product, i) => { + const tone = rankTones[i % rankTones.length]; + return ( + + + {i + 1} + + {product.ImgThumb ? ( + + ) : ( + + )} + + + {product.name} + + + {fmtCount(product.totalQuantity)} sold · {fmtCount(product.orderCount)}{" "} + {product.orderCount === 1 ? "order" : "orders"} + + + + {fmtMoney(product.totalRevenue)} + + + ); + })} +
+ )} +
+ + {/* last 30 days */} + + + + this 30d + + + + prev + + + } + > +
+ +
+
+ } + /> + } + /> + } + /> + } + /> +
+
+ + {/* inventory strip */} + Inventory + + + + + + + {/* footer stamp */} +
+ {dataUpdatedAt + ? `Updated ${new Date(dataUpdatedAt).toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + })} · refreshes automatically` + : "Loading…"} +
+
+
+ ); +}; + +export default StudioMobile; diff --git a/inventory/src/components/settings/GlobalSettings.tsx b/inventory/src/components/settings/GlobalSettings.tsx index 1dc86fc..c524339 100644 --- a/inventory/src/components/settings/GlobalSettings.tsx +++ b/inventory/src/components/settings/GlobalSettings.tsx @@ -89,21 +89,6 @@ export function GlobalSettings() { ); - } else if (setting.setting_key === 'default_forecast_method') { - return ( - - ); } else if (setting.setting_key.includes('threshold')) { // Percentage inputs return ( diff --git a/inventory/src/components/settings/ProductSettings.tsx b/inventory/src/components/settings/ProductSettings.tsx index 09b0585..ab8f2c5 100644 --- a/inventory/src/components/settings/ProductSettings.tsx +++ b/inventory/src/components/settings/ProductSettings.tsx @@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import config from '../../config'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Search } from 'lucide-react'; import { Switch } from "@/components/ui/switch"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -25,7 +24,6 @@ interface ProductSetting { lead_time_days: number | null; days_of_stock: number | null; safety_stock: number; - forecast_method: string | null; exclude_from_forecast: boolean; updated_at: string; product_name?: string; // Added for display purposes @@ -86,7 +84,6 @@ export function ProductSettings() { lead_time_days: setting.lead_time_days, days_of_stock: setting.days_of_stock, safety_stock: setting.safety_stock, - forecast_method: setting.forecast_method, exclude_from_forecast: setting.exclude_from_forecast }) }); @@ -195,7 +192,6 @@ export function ProductSettings() { Lead Time (days) Days of Stock Safety Stock - Forecast Method Exclude Actions @@ -231,21 +227,6 @@ export function ProductSettings() { onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)} /> - - - ({})); console.error('Auth check failed:', response.status, errorData); - // Any failed /me response means the session is invalid — logout - logout(); + // Only a genuine auth rejection ends the session. Server errors and + // rate limits (500/429/etc.) are transient — keep the session. + if (response.status === 401 || response.status === 403) { + logout(); + } else { + setError(errorData.error || `Auth check failed (${response.status})`); + } return; } diff --git a/inventory/src/hooks/useNow.ts b/inventory/src/hooks/useNow.ts new file mode 100644 index 0000000..f79e137 --- /dev/null +++ b/inventory/src/hooks/useNow.ts @@ -0,0 +1,26 @@ +import { useEffect, useState } from "react"; + +/** + * A Date that stays current: re-renders every `intervalMs` (default 1 min) + * and immediately when the tab becomes visible again — so greetings and + * date lines roll over even if the page sat open (or backgrounded on a + * phone) across a morning/afternoon/midnight boundary. + */ +export function useNow(intervalMs = 60_000): Date { + const [now, setNow] = useState(() => new Date()); + + useEffect(() => { + const update = () => setNow(new Date()); + const id = setInterval(update, intervalMs); + const onVisibility = () => { + if (document.visibilityState === "visible") update(); + }; + document.addEventListener("visibilitychange", onVisibility); + return () => { + clearInterval(id); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [intervalMs]); + + return now; +} diff --git a/inventory/src/pages/MobileDashboard.tsx b/inventory/src/pages/MobileDashboard.tsx new file mode 100644 index 0000000..f366021 --- /dev/null +++ b/inventory/src/pages/MobileDashboard.tsx @@ -0,0 +1,69 @@ +import React, { useEffect, useState } from "react"; +import { Navigate } from "react-router-dom"; +import PinProtection from "@/components/dashboard/PinProtection"; +import StudioMobile from "@/components/dashboard/mobile/StudioMobile"; +import PageLoading from "@/components/ui/page-loading"; +import { apiFetch } from "@/utils/api"; + +// Pin Protected Layout +const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => { + const [isPinVerified, setIsPinVerified] = useState(() => { + return sessionStorage.getItem("pinVerified") === "true"; + }); + + const handlePinSuccess = () => { + setIsPinVerified(true); + sessionStorage.setItem("pinVerified", "true"); + }; + + if (!isPinVerified) { + return ; + } + + return <>{children}; +}; + +// Same three-way gate as /small: office IP gets PIN, authenticated users skip +// PIN, everyone else is bounced to login. Identity comes from +// /api/dashboard/whoami (behind Caddy's office-IP allowlist for the kiosk case). +type Identity = "probing" | "kiosk" | "authenticated" | "anonymous"; + +const AccessGate = ({ children }: { children: React.ReactNode }) => { + const [identity, setIdentity] = useState("probing"); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await apiFetch("/api/dashboard/whoami"); + if (cancelled) return; + if (res.status === 401) { + setIdentity("anonymous"); + return; + } + const body = await res.json(); + setIdentity(body.is_kiosk ? "kiosk" : "authenticated"); + } catch { + if (!cancelled) setIdentity("anonymous"); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + if (identity === "probing") return ; + if (identity === "anonymous") return ; + if (identity === "kiosk") return {children}; + return <>{children}; +}; + +export function MobileDashboard() { + return ( + + + + ); +} + +export default MobileDashboard; diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index 404a8b1..29c2e44 100644 --- a/inventory/tsconfig.tsbuildinfo +++ b/inventory/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/customerservicedashboard.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/nightboard/nightboardsmall.tsx","./src/components/dashboard/shared/businessrangeselect.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/horizontabs.tsx","./src/components/dashboard/shared/studiocontrols.tsx","./src/components/dashboard/shared/index.ts","./src/components/dashboard/studio/sparkline.tsx","./src/components/dashboard/studio/studiostattile.tsx","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/mappingsignature.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/shared/index.ts","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/authed-image.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/config/uploads.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/api.ts","./src/utils/apiclient.ts","./src/utils/businesstime.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file +{"root":["./src/app.tsx","./src/config.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/config.ts","./src/components/ai/aidescriptioncompare.tsx","./src/components/analytics/agingsellthrough.tsx","./src/components/analytics/capitalefficiency.tsx","./src/components/analytics/discountimpact.tsx","./src/components/analytics/growthmomentum.tsx","./src/components/analytics/inventoryflow.tsx","./src/components/analytics/inventorytrends.tsx","./src/components/analytics/inventoryvaluetrend.tsx","./src/components/analytics/portfolioanalysis.tsx","./src/components/analytics/seasonalpatterns.tsx","./src/components/analytics/stockhealth.tsx","./src/components/analytics/stockoutrisk.tsx","./src/components/auth/firstaccessiblepage.tsx","./src/components/auth/protected.tsx","./src/components/auth/requireauth.tsx","./src/components/bulk-edit/bulkeditrow.tsx","./src/components/chat/chatroom.tsx","./src/components/chat/chattest.tsx","./src/components/chat/roomlist.tsx","./src/components/chat/searchresults.tsx","./src/components/create-po/addproductsdialog.tsx","./src/components/create-po/confirmationview.tsx","./src/components/create-po/lineitemstable.tsx","./src/components/create-po/pofloatingselectionbar.tsx","./src/components/create-po/reviewmatchesdialog.tsx","./src/components/create-po/supplierselector.tsx","./src/components/create-po/constants.ts","./src/components/create-po/parsespreadsheet.ts","./src/components/create-po/resolveidentifiers.ts","./src/components/create-po/types.ts","./src/components/dashboard/customerservicedashboard.tsx","./src/components/dashboard/financialoverview.tsx","./src/components/dashboard/operationsmetrics.tsx","./src/components/dashboard/payrollmetrics.tsx","./src/components/dashboard/periodselectionpopover.tsx","./src/components/dashboard/mobile/studiomobile.tsx","./src/components/dashboard/nightboard/nightboardsmall.tsx","./src/components/dashboard/shared/businessrangeselect.tsx","./src/components/dashboard/shared/dashboardbadge.tsx","./src/components/dashboard/shared/dashboardcharttooltip.tsx","./src/components/dashboard/shared/dashboardmultistatcardmini.tsx","./src/components/dashboard/shared/dashboardsectionheader.tsx","./src/components/dashboard/shared/dashboardskeleton.tsx","./src/components/dashboard/shared/dashboardstatcard.tsx","./src/components/dashboard/shared/dashboardstatcardmini.tsx","./src/components/dashboard/shared/dashboardstates.tsx","./src/components/dashboard/shared/dashboardtable.tsx","./src/components/dashboard/shared/horizontabs.tsx","./src/components/dashboard/shared/studiocontrols.tsx","./src/components/dashboard/shared/index.ts","./src/components/dashboard/studio/sparkline.tsx","./src/components/dashboard/studio/studiostattile.tsx","./src/components/discount-simulator/configpanel.tsx","./src/components/discount-simulator/resultschart.tsx","./src/components/discount-simulator/resultstable.tsx","./src/components/discount-simulator/summarycard.tsx","./src/components/forecasting/daterangepickerquick.tsx","./src/components/forecasting/columns.tsx","./src/components/layout/appsidebar.tsx","./src/components/layout/mainlayout.tsx","./src/components/layout/navuser.tsx","./src/components/newsletter/campaignhistorydialog.tsx","./src/components/newsletter/newsletterstats.tsx","./src/components/newsletter/recommendationtable.tsx","./src/components/overview/bestsellers.tsx","./src/components/overview/forecastaccuracy.tsx","./src/components/overview/forecastmetrics.tsx","./src/components/overview/overstockmetrics.tsx","./src/components/overview/purchasemetrics.tsx","./src/components/overview/replenishmentmetrics.tsx","./src/components/overview/salesmetrics.tsx","./src/components/overview/stockmetrics.tsx","./src/components/overview/topoverstockedproducts.tsx","./src/components/overview/topreplenishproducts.tsx","./src/components/product-editor/comboboxfield.tsx","./src/components/product-editor/editablecomboboxfield.tsx","./src/components/product-editor/editableinput.tsx","./src/components/product-editor/editablemultiselect.tsx","./src/components/product-editor/imagemanager.tsx","./src/components/product-editor/producteditform.tsx","./src/components/product-editor/productsearch.tsx","./src/components/product-editor/types.ts","./src/components/product-editor/useproductsuggestions.ts","./src/components/product-import/createproductcategorydialog.tsx","./src/components/product-import/reactspreadsheetimport.tsx","./src/components/product-import/config.ts","./src/components/product-import/index.ts","./src/components/product-import/translationsrsiprops.ts","./src/components/product-import/types.ts","./src/components/product-import/components/closeconfirmationdialog.tsx","./src/components/product-import/components/modalwrapper.tsx","./src/components/product-import/components/providers.tsx","./src/components/product-import/components/savesessiondialog.tsx","./src/components/product-import/components/savedsessionslist.tsx","./src/components/product-import/components/table.tsx","./src/components/product-import/hooks/usersi.ts","./src/components/product-import/steps/steps.tsx","./src/components/product-import/steps/uploadflow.tsx","./src/components/product-import/steps/imageuploadstep/imageuploadstep.tsx","./src/components/product-import/steps/imageuploadstep/types.ts","./src/components/product-import/steps/imageuploadstep/components/droppablecontainer.tsx","./src/components/product-import/steps/imageuploadstep/components/genericdropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/copybutton.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/imagedropzone.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/productcard.tsx","./src/components/product-import/steps/imageuploadstep/components/productcard/sortableimage.tsx","./src/components/product-import/steps/imageuploadstep/components/unassignedimagessection/unassignedimageitem.tsx","./src/components/product-import/steps/imageuploadstep/hooks/usebulkimageupload.ts","./src/components/product-import/steps/imageuploadstep/hooks/usedraganddrop.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimageoperations.ts","./src/components/product-import/steps/imageuploadstep/hooks/useproductimagesinit.ts","./src/components/product-import/steps/imageuploadstep/hooks/useurlimageupload.ts","./src/components/product-import/steps/matchcolumnsstep/matchcolumnsstep.tsx","./src/components/product-import/steps/matchcolumnsstep/types.ts","./src/components/product-import/steps/matchcolumnsstep/components/matchicon.tsx","./src/components/product-import/steps/matchcolumnsstep/components/templatecolumn.tsx","./src/components/product-import/steps/matchcolumnsstep/utils/findmatch.ts","./src/components/product-import/steps/matchcolumnsstep/utils/findunmatchedrequiredfields.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getfieldoptions.ts","./src/components/product-import/steps/matchcolumnsstep/utils/getmatchedcolumns.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizecheckboxvalue.ts","./src/components/product-import/steps/matchcolumnsstep/utils/normalizetabledata.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setignorecolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/setsubcolumn.ts","./src/components/product-import/steps/matchcolumnsstep/utils/uniqueentries.ts","./src/components/product-import/steps/selectheaderstep/selectheaderstep.tsx","./src/components/product-import/steps/selectheaderstep/components/selectheadertable.tsx","./src/components/product-import/steps/selectheaderstep/components/columns.tsx","./src/components/product-import/steps/selectsheetstep/selectsheetstep.tsx","./src/components/product-import/steps/uploadstep/uploadstep.tsx","./src/components/product-import/steps/uploadstep/components/dropzone.tsx","./src/components/product-import/steps/uploadstep/components/columns.tsx","./src/components/product-import/steps/uploadstep/utils/readfilesasync.ts","./src/components/product-import/steps/validationstep/index.tsx","./src/components/product-import/steps/validationstep/components/aisuggestionbadge.tsx","./src/components/product-import/steps/validationstep/components/copydownbanner.tsx","./src/components/product-import/steps/validationstep/components/floatingselectionbar.tsx","./src/components/product-import/steps/validationstep/components/initializingoverlay.tsx","./src/components/product-import/steps/validationstep/components/searchabletemplateselect.tsx","./src/components/product-import/steps/validationstep/components/suggestionbadges.tsx","./src/components/product-import/steps/validationstep/components/validationcontainer.tsx","./src/components/product-import/steps/validationstep/components/validationfooter.tsx","./src/components/product-import/steps/validationstep/components/validationtable.tsx","./src/components/product-import/steps/validationstep/components/validationtoolbar.tsx","./src/components/product-import/steps/validationstep/components/cells/checkboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/comboboxcell.tsx","./src/components/product-import/steps/validationstep/components/cells/inputcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multiselectcell.tsx","./src/components/product-import/steps/validationstep/components/cells/multilineinput.tsx","./src/components/product-import/steps/validationstep/components/cells/selectcell.tsx","./src/components/product-import/steps/validationstep/contexts/aisuggestionscontext.tsx","./src/components/product-import/steps/validationstep/dialogs/aidebugdialog.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationprogress.tsx","./src/components/product-import/steps/validationstep/dialogs/aivalidationresults.tsx","./src/components/product-import/steps/validationstep/dialogs/sanitycheckdialog.tsx","./src/components/product-import/steps/validationstep/hooks/useautoinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/usecopydownvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usefieldoptions.ts","./src/components/product-import/steps/validationstep/hooks/useinlineaivalidation.ts","./src/components/product-import/steps/validationstep/hooks/useproductlines.ts","./src/components/product-import/steps/validationstep/hooks/usesanitycheck.ts","./src/components/product-import/steps/validationstep/hooks/usetemplatemanagement.ts","./src/components/product-import/steps/validationstep/hooks/useupcvalidation.ts","./src/components/product-import/steps/validationstep/hooks/usevalidationactions.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/index.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiapi.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaiprogress.ts","./src/components/product-import/steps/validationstep/hooks/useaivalidation/useaitransform.ts","./src/components/product-import/steps/validationstep/store/selectors.ts","./src/components/product-import/steps/validationstep/store/types.ts","./src/components/product-import/steps/validationstep/store/validationstore.ts","./src/components/product-import/steps/validationstep/utils/aivalidationutils.ts","./src/components/product-import/steps/validationstep/utils/countryutils.ts","./src/components/product-import/steps/validationstep/utils/datamutations.ts","./src/components/product-import/steps/validationstep/utils/inlineaipayload.ts","./src/components/product-import/steps/validationstep/utils/mappingsignature.ts","./src/components/product-import/steps/validationstep/utils/priceutils.ts","./src/components/product-import/steps/validationstep/utils/upcutils.ts","./src/components/product-import/utils/exceedsmaxrecords.ts","./src/components/product-import/utils/mapdata.ts","./src/components/product-import/utils/mapworkbook.ts","./src/components/product-import/utils/steps.ts","./src/components/products/productdetail.tsx","./src/components/products/productfilters.tsx","./src/components/products/productsummarycards.tsx","./src/components/products/producttable.tsx","./src/components/products/producttableskeleton.tsx","./src/components/products/productviews.tsx","./src/components/products/statusbadge.tsx","./src/components/products/columndefinitions.ts","./src/components/purchase-orders/categorymetricscard.tsx","./src/components/purchase-orders/filtercontrols.tsx","./src/components/purchase-orders/ordermetricscard.tsx","./src/components/purchase-orders/paginationcontrols.tsx","./src/components/purchase-orders/pipelinecard.tsx","./src/components/purchase-orders/purchaseorderaccordion.tsx","./src/components/purchase-orders/purchaseorderstable.tsx","./src/components/purchase-orders/vendormetricscard.tsx","./src/components/settings/auditlog.tsx","./src/components/settings/datamanagement.tsx","./src/components/settings/globalsettings.tsx","./src/components/settings/permissionselector.tsx","./src/components/settings/productsettings.tsx","./src/components/settings/promptmanagement.tsx","./src/components/settings/reusableimagemanagement.tsx","./src/components/settings/templatemanagement.tsx","./src/components/settings/userform.tsx","./src/components/settings/userlist.tsx","./src/components/settings/usermanagement.tsx","./src/components/settings/vendorsettings.tsx","./src/components/shared/index.ts","./src/components/templates/searchproducttemplatedialog.tsx","./src/components/templates/templateform.tsx","./src/components/ui/accordion.tsx","./src/components/ui/alert-dialog.tsx","./src/components/ui/alert.tsx","./src/components/ui/authed-image.tsx","./src/components/ui/avatar.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/calendar.tsx","./src/components/ui/card.tsx","./src/components/ui/carousel.tsx","./src/components/ui/checkbox.tsx","./src/components/ui/code.tsx","./src/components/ui/collapsible.tsx","./src/components/ui/command.tsx","./src/components/ui/date-range-picker.tsx","./src/components/ui/dialog.tsx","./src/components/ui/drawer.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/form.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/page-loading.tsx","./src/components/ui/pagination.tsx","./src/components/ui/popover.tsx","./src/components/ui/progress.tsx","./src/components/ui/radio-group.tsx","./src/components/ui/scroll-area.tsx","./src/components/ui/select.tsx","./src/components/ui/separator.tsx","./src/components/ui/sheet.tsx","./src/components/ui/sidebar.tsx","./src/components/ui/skeleton.tsx","./src/components/ui/sonner.tsx","./src/components/ui/switch.tsx","./src/components/ui/table.tsx","./src/components/ui/tabs.tsx","./src/components/ui/textarea.tsx","./src/components/ui/toggle-group.tsx","./src/components/ui/toggle.tsx","./src/components/ui/tooltip.tsx","./src/config/dashboard.ts","./src/config/uploads.ts","./src/contexts/authcontext.tsx","./src/contexts/dashboardscrollcontext.tsx","./src/contexts/importsessioncontext.tsx","./src/hooks/use-mobile.tsx","./src/hooks/usedebounce.ts","./src/hooks/useimportautosave.ts","./src/hooks/usenow.ts","./src/lib/utils.ts","./src/lib/dashboard/chartconfig.ts","./src/lib/dashboard/designtokens.ts","./src/pages/analytics.tsx","./src/pages/blackfridaydashboard.tsx","./src/pages/brands.tsx","./src/pages/bulkedit.tsx","./src/pages/categories.tsx","./src/pages/chat.tsx","./src/pages/createpurchaseorder.tsx","./src/pages/dashboard.tsx","./src/pages/discountsimulator.tsx","./src/pages/forecasting.tsx","./src/pages/htslookup.tsx","./src/pages/import.tsx","./src/pages/login.tsx","./src/pages/mobiledashboard.tsx","./src/pages/newsletter.tsx","./src/pages/overview.tsx","./src/pages/producteditor.tsx","./src/pages/productlines.tsx","./src/pages/products.tsx","./src/pages/purchaseorders.tsx","./src/pages/repeatorders.tsx","./src/pages/settings.tsx","./src/pages/smalldashboard.tsx","./src/pages/speclookup.tsx","./src/services/apiv2.ts","./src/services/importauditlogapi.ts","./src/services/importsessionapi.ts","./src/services/producteditor.ts","./src/services/producteditorauditlog.ts","./src/types/dashboard-shims.d.ts","./src/types/dashboard.d.ts","./src/types/discount-simulator.ts","./src/types/globals.d.ts","./src/types/importsession.ts","./src/types/products.ts","./src/types/react-data-grid.d.ts","./src/types/status-codes.ts","./src/utils/api.ts","./src/utils/apiclient.ts","./src/utils/businesstime.ts","./src/utils/emojiutils.ts","./src/utils/formatcurrency.ts","./src/utils/lifecyclephases.ts","./src/utils/naturallanguageperiod.ts","./src/utils/productutils.ts","./src/utils/transformutils.ts"],"version":"5.6.3"} \ No newline at end of file