diff --git a/inventory-server/dashboard/routes/freescout/index.js b/inventory-server/dashboard/routes/freescout/index.js new file mode 100644 index 0000000..6e3cb9a --- /dev/null +++ b/inventory-server/dashboard/routes/freescout/index.js @@ -0,0 +1,30 @@ +// FreeScout customer-service report router. Read-only — authenticated-only is +// sufficient (same stance as Google/Typeform); no requirePermission() gate. + +import express from 'express'; +import { FreescoutService } from '../../services/freescout/freescout.service.js'; + +const ALLOWED_DAYS = new Set([7, 14, 30, 90, 180, 365]); + +export function createFreescoutRouter({ redis, freescoutPool, phonePool }) { + const router = express.Router(); + const freescout = new FreescoutService(redis, freescoutPool, phonePool); + + router.get('/report', async (req, res) => { + const days = Number(req.query.days) || 30; + if (!ALLOWED_DAYS.has(days)) { + return res.status(400).json({ + error: 'Invalid days parameter', + details: `days must be one of ${[...ALLOWED_DAYS].join(', ')}`, + }); + } + try { + res.json(await freescout.getReport(days)); + } catch (error) { + console.error('FreeScout report error:', { days, error: error.message }); + res.status(500).json({ error: 'Failed to build FreeScout report', details: error.message }); + } + }); + + return router; +} diff --git a/inventory-server/dashboard/scripts/test-freescout-report.mjs b/inventory-server/dashboard/scripts/test-freescout-report.mjs new file mode 100644 index 0000000..448d32b --- /dev/null +++ b/inventory-server/dashboard/scripts/test-freescout-report.mjs @@ -0,0 +1,37 @@ +// Smoke test for the FreeScout CS report service, bypassing HTTP auth. +// Run on the server: cd /var/www/inventory/dashboard && node scripts/test-freescout-report.mjs [days] + +import { config as loadEnv } from 'dotenv'; +import { createPool } from '../../shared/db/pg.js'; +import { FreescoutService } from '../services/freescout/freescout.service.js'; + +loadEnv({ path: new URL('../.env', import.meta.url).pathname }); + +const days = Number(process.argv[2]) || 30; + +const freescoutPool = createPool('FREESCOUT_DB', { + user: process.env.FREESCOUT_DB_USERNAME, + database: process.env.FREESCOUT_DB_DATABASE, + max: 2, +}); +const phonePool = process.env.ACOT_PHONE_DB_HOST + ? createPool('ACOT_PHONE_DB', { + user: process.env.ACOT_PHONE_DB_USERNAME, + database: process.env.ACOT_PHONE_DB_DATABASE, + max: 2, + }) + : null; + +// Stub redis in "end" status so the service skips caching entirely. +const redisStub = { status: 'end' }; + +const service = new FreescoutService(redisStub, freescoutPool, phonePool); +const t0 = Date.now(); +try { + const report = await service.getReport(days); + console.log(JSON.stringify(report, null, 2)); + console.error(`\nOK — ${days}d report in ${Date.now() - t0}ms`); +} finally { + await freescoutPool.end(); + await phonePool?.end(); +} diff --git a/inventory-server/dashboard/server.js b/inventory-server/dashboard/server.js index ab1ffd7..fd8fb9d 100644 --- a/inventory-server/dashboard/server.js +++ b/inventory-server/dashboard/server.js @@ -7,6 +7,7 @@ // /api/meta/* → routes/meta (was meta-server :3005) // /api/dashboard-analytics/* → routes/google (was google-server :3007 via Caddy /api/analytics rewrite) // /api/typeform/* → routes/typeform (was typeform-server :3008) +// /api/freescout/* → routes/freescout (FreeScout + acot_phone CS report, new) // // Shared infrastructure (Phase 2 + Phase 6): // - shared/auth/middleware.js authenticate() guards /api/* (Phase 6.1/6.2 — second line of defense) @@ -39,6 +40,7 @@ import { createKlaviyoRouter } from './routes/klaviyo/index.js'; import { createMetaRouter } from './routes/meta/index.js'; import { createGoogleRouter } from './routes/google/index.js'; import { createTypeformRouter } from './routes/typeform/index.js'; +import { createFreescoutRouter } from './routes/freescout/index.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -76,6 +78,25 @@ const pool = createPool('DB'); // if Redis is temporarily unavailable, and aligns with shared/db/redis.js defaults. const redis = createRedis(); +// Read-only pools for the FreeScout CS section. The .env keys use USERNAME / +// DATABASE where createPool() reads USER / NAME, hence the overrides. Both are +// optional: without FREESCOUT_DB_* the route isn't mounted; without +// ACOT_PHONE_DB_* the report's phone block is null. +const freescoutPool = process.env.FREESCOUT_DB_HOST + ? createPool('FREESCOUT_DB', { + user: process.env.FREESCOUT_DB_USERNAME, + database: process.env.FREESCOUT_DB_DATABASE, + max: 5, + }) + : null; +const phonePool = process.env.ACOT_PHONE_DB_HOST + ? createPool('ACOT_PHONE_DB', { + user: process.env.ACOT_PHONE_DB_USERNAME, + database: process.env.ACOT_PHONE_DB_DATABASE, + max: 3, + }) + : null; + app.use(requestLog()); app.use(cors(corsOptions)); app.use(express.json({ limit: '10mb' })); @@ -91,6 +112,11 @@ app.use('/api/meta', createMetaRouter()); // Caddy can drop the rewrite — see Caddyfile.proposed. app.use('/api/dashboard-analytics', createGoogleRouter({ redis })); app.use('/api/typeform', createTypeformRouter({ redis })); +if (freescoutPool) { + app.use('/api/freescout', createFreescoutRouter({ redis, freescoutPool, phonePool })); +} else { + logger.warn('FREESCOUT_DB_* not set — /api/freescout not mounted'); +} app.get('/health', (req, res) => { res.json({ @@ -118,6 +144,8 @@ const shutdown = async (signal) => { server.close(); try { await redis.quit(); } catch { /* ignore */ } try { await pool.end(); } catch { /* ignore */ } + try { await freescoutPool?.end(); } catch { /* ignore */ } + try { await phonePool?.end(); } catch { /* ignore */ } process.exit(0); }; process.on('SIGTERM', () => shutdown('SIGTERM')); diff --git a/inventory-server/dashboard/services/freescout/freescout.service.js b/inventory-server/dashboard/services/freescout/freescout.service.js new file mode 100644 index 0000000..76831e4 --- /dev/null +++ b/inventory-server/dashboard/services/freescout/freescout.service.js @@ -0,0 +1,409 @@ +// FreeScout customer-service report — direct read-only queries against the +// freescout and acot_phone Postgres databases (same instance as inventory_db). +// FreeScout's Reports module has no JSON API (its /reports/ajax needs an agent +// session cookie and returns rendered Blade HTML), so we reproduce its formulas +// from Modules/Reports/Http/Controllers/ReportsController.php instead. +// +// Timezone: freescout.* timestamp columns are NAIVE literals in America/New_York +// (Laravel APP_TZ). Every comparison re-anchors them with AT TIME ZONE +// 'America/New_York'; day bucketing then converts to America/Chicago business +// days per shared/business-time. acot_phone columns are proper timestamptz. +// +// First-response / resolution times are NOT recomputed from threads — FreeScout's +// `freescout:reports-collect-data` cron precomputes them into conversations.meta +// under the "rpt" key (frt / rst / rnt / rtr / rfr, seconds). We read that JSON. + +import { DateTime } from 'luxon'; +import { BUSINESS_TZ } from '../../../shared/business-time/index.js'; + +// Enum values from freescout app/Conversation.php and app/Thread.php, and +// Modules/SatRatings (rating: 1=great 2=okay 3=bad). +const CONV_STATE_PUBLISHED = 2; +const CONV_STATUS_CLOSED = 3; +const CONV_STATUS_SPAM = 4; +const CONV_TYPES = { 1: 'email', 2: 'phone', 3: 'chat' }; +const THREAD_TYPE_CUSTOMER = 1; +const THREAD_TYPE_MESSAGE = 2; + +// FreeScout's report-table time buckets (Reports module getTimeTablePattern), +// used for the first-response-time histogram. Thresholds in seconds; SQL +// width_bucket() returns the index into this list. +const FRT_BUCKET_LABELS = ['<15m', '15-30m', '30-60m', '1-2h', '2-3h', '3-6h', '6-12h', '12-24h', '1-2d', '>2d']; +const FRT_BUCKET_THRESHOLDS = [900, 1800, 3600, 7200, 10800, 21600, 43200, 86400, 172800]; + +// Re-anchor a naive America/New_York timestamp column as timestamptz. +const ny = (col) => `(${col} AT TIME ZONE 'America/New_York')`; +// Bucket it to an America/Chicago business date. +const nyBizDate = (col) => `(${ny(col)} AT TIME ZONE '${BUSINESS_TZ}')::date`; + +const num = (v) => (v === null || v === undefined ? null : Number(v)); + +export class FreescoutService { + constructor(redis, freescoutPool, phonePool = null) { + if (!redis) throw new Error('FreescoutService requires an ioredis client'); + if (!freescoutPool) throw new Error('FreescoutService requires the freescout pg Pool'); + this.redis = redis; + this.fs = freescoutPool; + this.phone = phonePool; // optional — phone card hidden when absent + } + + get _redisReady() { + return this.redis.status === 'ready' || this.redis.status === 'connect'; + } + + async _cacheGet(key) { + if (!this._redisReady) return null; + try { + const raw = await this.redis.get(key); + return raw ? JSON.parse(raw) : null; + } catch (err) { + console.warn('[Freescout] cache get failed:', err.message); + return null; + } + } + + async _cacheSet(key, value, ttlSec) { + if (!this._redisReady) return; + try { + await this.redis.setex(key, ttlSec, JSON.stringify(value)); + } catch (err) { + console.warn('[Freescout] cache set failed:', err.message); + } + } + + async getReport(days) { + const cacheKey = `freescout:report:v2:${days}`; + const cached = await this._cacheGet(cacheKey); + if (cached) return cached; + + const todayStart = DateTime.now().setZone(BUSINESS_TZ).startOf('day'); + const end = todayStart.plus({ days: 1 }); // exclusive — current period includes today-so-far + const start = todayStart.minus({ days: days - 1 }); + const prevEnd = start; + const prevStart = start.minus({ days }); + const cur = [start.toISO(), end.toISO()]; + const prev = [prevStart.toISO(), prevEnd.toISO()]; + + const [ + overview, overviewPrev, + times, timesPrev, + satisfaction, satisfactionPrev, + timeseries, channels, agents, + frtDistribution, + phone, phonePrev, + ] = await Promise.all([ + this._overview(cur), this._overview(prev), + this._responseTimes(cur), this._responseTimes(prev), + this._satisfaction(cur), this._satisfaction(prev), + this._timeseries(cur, start, todayStart), + this._channels(cur), + this._agents(cur), + this._frtDistribution(cur), + this._phoneStats(cur), this._phoneStats(prev), + ]); + + const report = { + range: { + days, + start: start.toISODate(), + end: todayStart.toISODate(), + prevStart: prevStart.toISODate(), + prevEnd: prevEnd.minus({ days: 1 }).toISODate(), + }, + overview: { current: overview, previous: overviewPrev }, + responseTimes: { current: times, previous: timesPrev }, + satisfaction: { current: satisfaction, previous: satisfactionPrev }, + timeseries, + channels, + agents, + frtDistribution, + phone: phone ? { current: phone, previous: phonePrev } : null, + }; + + await this._cacheSet(cacheKey, report, 300); + return report; + } + + // Headline counts. Formulas mirror ReportsController: newConversations = + // countNewConv, messagesReceived = countMessages (customer threads), + // repliesSent = countRepliesSent, customersHelped = countCustomersHelped. + // "resolved" uses conversations.closed_at (simpler than the module's + // closing-thread reconstruction, same intent: closes that happened in range). + async _overview([from, to]) { + const { rows } = await this.fs.query( + `SELECT + (SELECT count(*) FROM conversations + WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM} + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS new_conversations, + (SELECT count(*) FROM conversations + WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED} + AND closed_at IS NOT NULL + AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2) AS resolved, + (SELECT count(*) FROM threads + WHERE type = ${THREAD_TYPE_CUSTOMER} + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS messages_received, + (SELECT count(*) FROM threads + WHERE type = ${THREAD_TYPE_MESSAGE} AND state = ${CONV_STATE_PUBLISHED} + AND created_by_user_id IS NOT NULL + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2) AS replies_sent, + (SELECT count(DISTINCT c.customer_id) + FROM threads t JOIN conversations c ON c.id = t.conversation_id + WHERE t.type = ${THREAD_TYPE_MESSAGE} AND t.state = ${CONV_STATE_PUBLISHED} + AND t.created_by_user_id IS NOT NULL + AND ${ny('t.created_at')} >= $1 AND ${ny('t.created_at')} < $2) AS customers_helped`, + [from, to], + ); + const r = rows[0]; + return { + newConversations: num(r.new_conversations), + resolved: num(r.resolved), + messagesReceived: num(r.messages_received), + repliesSent: num(r.replies_sent), + customersHelped: num(r.customers_helped), + }; + } + + // Precomputed per-conversation metrics from conversations.meta->rpt (seconds): + // frt = first response time, rnt = resolution time, rfr = resolved on first reply. + async _responseTimes([from, to]) { + const { rows } = await this.fs.query( + `SELECT count(frt) AS frt_count, + round(avg(frt)) AS frt_avg, + round(percentile_cont(0.5) WITHIN GROUP (ORDER BY frt)) AS frt_median, + count(rnt) AS rnt_count, + round(avg(rnt)) AS rnt_avg, + round(percentile_cont(0.5) WITHIN GROUP (ORDER BY rnt)) AS rnt_median, + count(*) FILTER (WHERE rfr) AS rfr_count + FROM ( + SELECT nullif(meta::json #>> '{rpt,frt}', '')::numeric AS frt, + nullif(meta::json #>> '{rpt,rnt}', '')::numeric AS rnt, + (meta::json #>> '{rpt,rfr}') IN ('1', 'true') AS rfr + FROM conversations + WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM} + AND meta LIKE '{%' AND meta LIKE '%"rpt"%' + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2 + ) rpt`, + [from, to], + ); + const r = rows[0]; + return { + firstResponse: { count: num(r.frt_count), avgSec: num(r.frt_avg), medianSec: num(r.frt_median) }, + resolution: { count: num(r.rnt_count), avgSec: num(r.rnt_avg), medianSec: num(r.rnt_median) }, + resolvedFirstReply: num(r.rfr_count), + }; + } + + // Histogram of first-response times (current period only), zero-filled over + // FreeScout's bucket boundaries. + async _frtDistribution([from, to]) { + const { rows } = await this.fs.query( + `SELECT width_bucket(frt, ARRAY[${FRT_BUCKET_THRESHOLDS.join(',')}]::numeric[]) AS bucket, + count(*) AS n + FROM ( + SELECT nullif(meta::json #>> '{rpt,frt}', '')::numeric AS frt + FROM conversations + WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM} + AND meta LIKE '{%' AND meta LIKE '%"rpt"%' + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2 + ) rpt + WHERE frt IS NOT NULL + GROUP BY 1`, + [from, to], + ); + const counts = new Array(FRT_BUCKET_LABELS.length).fill(0); + for (const r of rows) counts[Number(r.bucket)] = Number(r.n); + return FRT_BUCKET_LABELS.map((label, i) => ({ bucket: label, count: counts[i] })); + } + + // Ratings live on the rated reply thread (threads.rating, SatRatings module). + // Score formula per calcSatisfactionScore: ceil(great% - bad%). + async _satisfaction([from, to]) { + const { rows } = await this.fs.query( + `SELECT count(*) FILTER (WHERE rating = 1) AS great, + count(*) FILTER (WHERE rating = 2) AS okay, + count(*) FILTER (WHERE rating = 3) AS bad + FROM threads + WHERE rating IS NOT NULL AND rating > 0 + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2`, + [from, to], + ); + const great = num(rows[0].great); + const okay = num(rows[0].okay); + const bad = num(rows[0].bad); + const total = great + okay + bad; + return { + great, + okay, + bad, + total, + score: total ? Math.ceil((great * 100) / total - (bad * 100) / total) : null, + }; + } + + // Per-business-day new / resolved / customer messages, zero-filled. + async _timeseries([from, to], start, todayStart) { + const [news, closes, msgs] = await Promise.all([ + this.fs.query( + // ::text so pg returns 'YYYY-MM-DD' strings — the driver's DATE→JS Date + // parsing is zone-ambiguous and can shift the day + `SELECT ${nyBizDate('created_at')}::text AS day, count(*) AS n + FROM conversations + WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM} + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2 + GROUP BY 1`, + [from, to], + ), + this.fs.query( + `SELECT ${nyBizDate('closed_at')}::text AS day, count(*) AS n + FROM conversations + WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED} + AND closed_at IS NOT NULL + AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2 + GROUP BY 1`, + [from, to], + ), + this.fs.query( + `SELECT ${nyBizDate('created_at')}::text AS day, count(*) AS n + FROM threads + WHERE type = ${THREAD_TYPE_CUSTOMER} + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2 + GROUP BY 1`, + [from, to], + ), + ]); + + const toMap = (res) => new Map(res.rows.map((row) => [row.day, Number(row.n)])); + const newsM = toMap(news); + const closesM = toMap(closes); + const msgsM = toMap(msgs); + + const series = []; + for (let d = start; d <= todayStart; d = d.plus({ days: 1 })) { + const key = d.toISODate(); + series.push({ + date: key, + newConversations: newsM.get(key) ?? 0, + resolved: closesM.get(key) ?? 0, + messages: msgsM.get(key) ?? 0, + }); + } + return series; + } + + async _channels([from, to]) { + const { rows } = await this.fs.query( + `SELECT type, count(*) AS n + FROM conversations + WHERE state = ${CONV_STATE_PUBLISHED} AND status <> ${CONV_STATUS_SPAM} + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2 + GROUP BY type`, + [from, to], + ); + const channels = { email: 0, phone: 0, chat: 0, other: 0 }; + for (const r of rows) { + channels[CONV_TYPES[r.type] ?? 'other'] += Number(r.n); + } + return channels; + } + + // Per-agent table: replies + customers helped + ratings keyed off the reply + // thread's author; closes keyed off conversations.closed_by_user_id. + async _agents([from, to]) { + const [replies, ratings, closes, users] = await Promise.all([ + this.fs.query( + `SELECT t.created_by_user_id AS user_id, + count(*) AS replies, + count(DISTINCT c.customer_id) AS customers_helped + FROM threads t JOIN conversations c ON c.id = t.conversation_id + WHERE t.type = ${THREAD_TYPE_MESSAGE} AND t.state = ${CONV_STATE_PUBLISHED} + AND t.created_by_user_id IS NOT NULL + AND ${ny('t.created_at')} >= $1 AND ${ny('t.created_at')} < $2 + GROUP BY 1`, + [from, to], + ), + this.fs.query( + `SELECT created_by_user_id AS user_id, + count(*) FILTER (WHERE rating = 1) AS great, + count(*) FILTER (WHERE rating = 2) AS okay, + count(*) FILTER (WHERE rating = 3) AS bad + FROM threads + WHERE rating IS NOT NULL AND rating > 0 AND created_by_user_id IS NOT NULL + AND ${ny('created_at')} >= $1 AND ${ny('created_at')} < $2 + GROUP BY 1`, + [from, to], + ), + this.fs.query( + `SELECT closed_by_user_id AS user_id, count(*) AS closed + FROM conversations + WHERE state = ${CONV_STATE_PUBLISHED} AND status = ${CONV_STATUS_CLOSED} + AND closed_at IS NOT NULL AND closed_by_user_id IS NOT NULL + AND ${ny('closed_at')} >= $1 AND ${ny('closed_at')} < $2 + GROUP BY 1`, + [from, to], + ), + // type=1 excludes the Workflows robot user + this.fs.query(`SELECT id, first_name, last_name FROM users WHERE type = 1`), + ]); + + const agents = new Map(); + const entry = (id) => { + if (!agents.has(id)) { + agents.set(id, { + id, name: null, replies: 0, customersHelped: 0, closed: 0, + great: 0, okay: 0, bad: 0, satScore: null, + }); + } + return agents.get(id); + }; + for (const r of replies.rows) { + const a = entry(r.user_id); + a.replies = Number(r.replies); + a.customersHelped = Number(r.customers_helped); + } + for (const r of closes.rows) entry(r.user_id).closed = Number(r.closed); + for (const r of ratings.rows) { + const a = entry(r.user_id); + a.great = Number(r.great); + a.okay = Number(r.okay); + a.bad = Number(r.bad); + const total = a.great + a.okay + a.bad; + if (total) a.satScore = Math.ceil((a.great * 100) / total - (a.bad * 100) / total); + } + + const names = new Map(users.rows.map((u) => [u.id, `${u.first_name} ${u.last_name}`.trim()])); + return [...agents.values()] + .filter((a) => names.has(a.id)) // drop deleted/robot authors + .map((a) => ({ ...a, name: names.get(a.id) })) + .sort((a, b) => b.replies - a.replies); + } + + // acot_phone bridge DB (timestamptz — no NY re-anchoring). Inbound calls always + // get answered_at set by the PBX, so "missed" is the voicemail count, not + // answered_at IS NULL. + async _phoneStats([from, to]) { + if (!this.phone) return null; + const [calls, vms] = await Promise.all([ + this.phone.query( + `SELECT count(*) AS total, + count(*) FILTER (WHERE direction = 'inbound') AS inbound, + count(*) FILTER (WHERE direction = 'outbound') AS outbound, + round(avg(duration_seconds) FILTER (WHERE answered_at IS NOT NULL)) AS avg_duration_sec + FROM calls + WHERE started_at >= $1 AND started_at < $2`, + [from, to], + ), + this.phone.query( + `SELECT count(*) AS voicemails FROM voicemails WHERE created_at >= $1 AND created_at < $2`, + [from, to], + ), + ]); + const c = calls.rows[0]; + return { + total: num(c.total), + inbound: num(c.inbound), + outbound: num(c.outbound), + avgDurationSec: num(c.avg_duration_sec), + voicemails: num(vms.rows[0].voicemails), + }; + } +} diff --git a/inventory/src/components/dashboard/CustomerServiceDashboard.tsx b/inventory/src/components/dashboard/CustomerServiceDashboard.tsx new file mode 100644 index 0000000..11a24ad --- /dev/null +++ b/inventory/src/components/dashboard/CustomerServiceDashboard.tsx @@ -0,0 +1,628 @@ +import { useEffect, useMemo, useState } from "react"; +import apiClient from "@/utils/apiClient"; +import { Card, CardContent } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Bar, + BarChart, + CartesianGrid, + ComposedChart, + LabelList, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TooltipProps } from "recharts"; +import { MessagesSquare, TrendingUp } from "lucide-react"; +import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens"; +import { + DashboardSectionHeader, + DashboardStatCard, + DashboardStatCardSkeleton, + ChartSkeleton, + DashboardEmptyState, + DashboardErrorState, + TOOLTIP_STYLES, + MetricPill, + QuietStat, + QUIET_STRIP_CLASS, +} from "@/components/dashboard/shared"; +import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect"; + +type PeriodPair = { current: T; previous: T }; + +type OverviewStats = { + newConversations: number; + resolved: number; + messagesReceived: number; + repliesSent: number; + customersHelped: number; +}; + +type ResponseTimeStats = { + firstResponse: { count: number; avgSec: number | null; medianSec: number | null }; + resolution: { count: number; avgSec: number | null; medianSec: number | null }; + resolvedFirstReply: number; +}; + +type SatisfactionStats = { + great: number; + okay: number; + bad: number; + total: number; + score: number | null; +}; + +type TimeseriesPoint = { + date: string; + newConversations: number; + resolved: number; + messages: number; +}; + +type AgentEntry = { + id: number; + name: string; + replies: number; + customersHelped: number; + closed: number; + great: number; + okay: number; + bad: number; + satScore: number | null; +}; + +type PhoneStats = { + total: number; + inbound: number; + outbound: number; + avgDurationSec: number | null; + voicemails: number; +}; + +type FrtBucket = { bucket: string; count: number }; + +type FreescoutReport = { + range: { days: number; start: string; end: string; prevStart: string; prevEnd: string }; + overview: PeriodPair; + responseTimes: PeriodPair; + satisfaction: PeriodPair; + timeseries: TimeseriesPoint[]; + channels: { email: number; phone: number; chat: number; other: number }; + agents: AgentEntry[]; + frtDistribution: FrtBucket[]; + phone: PeriodPair | null; +}; + +type ChartSeriesKey = "newConversations" | "resolved" | "messages"; + +// Sorbet Studio data hues, deliberately teal-led: Operations leads with a violet +// area, so this section leads with teal bars to keep the two visually distinct. +// Triple passes the CVD/contrast validator (ΔE ≥ 27). +const chartColors: Record = { + newConversations: STUDIO_COLORS.teal, + resolved: STUDIO_COLORS.violet, + messages: STUDIO_COLORS.amber, +}; + +const SERIES_LABELS: Record = { + newConversations: "New", + resolved: "Resolved", + messages: "Messages", +}; + +const RANGE_OPTIONS = [ + { value: "7", label: "Last 7 Days" }, + { value: "30", label: "Last 30 Days" }, + { value: "90", label: "Last 90 Days" }, + { value: "180", label: "Last 180 Days" }, + { value: "365", label: "Last Year" }, +]; + +const REFRESH_MS = 10 * 60 * 1000; + +const formatNumber = (value: number) => + Number.isFinite(value) ? value.toLocaleString("en-US") : "0"; + +// Compact duration: 45s, 12m, 3.5h, 2.1d +const formatSeconds = (seconds: number | null | undefined): string => { + if (seconds == null || !Number.isFinite(seconds)) return "—"; + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) return `${Math.round(seconds / 60)}m`; + if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`; + return `${(seconds / 86400).toFixed(1)}d`; +}; + +const trendPct = (current: number, previous: number): number | null => + previous > 0 ? ((current - previous) / previous) * 100 : null; + +const formatDayLabel = (date: string) => + new Date(`${date}T00:00:00`).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + +const CustomerServiceDashboard = () => { + const [days, setDays] = useState("30"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [metrics, setMetrics] = useState>({ + newConversations: true, + resolved: true, + messages: false, + }); + + useEffect(() => { + let cancelled = false; + + const fetchReport = async (isRefresh = false) => { + if (!isRefresh) { + setLoading(true); + setError(null); + } + try { + const response = await apiClient.get("/api/freescout/report", { + params: { days }, + }); + if (!cancelled) setData(response.data); + } catch (err: unknown) { + if (!cancelled && !isRefresh) { + const maybe = err as { response?: { data?: { error?: string } }; message?: string }; + setError(maybe.response?.data?.error ?? maybe.message ?? "Request failed"); + } + } finally { + if (!cancelled && !isRefresh) setLoading(false); + } + }; + + void fetchReport(); + const interval = setInterval(() => void fetchReport(true), REFRESH_MS); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [days]); + + const toggleMetric = (key: ChartSeriesKey) => + setMetrics((prev) => ({ ...prev, [key]: !prev[key] })); + + const hasActiveMetrics = Object.values(metrics).some(Boolean); + + const chartData = useMemo( + () => + (data?.timeseries ?? []).map((point) => ({ + ...point, + label: formatDayLabel(point.date), + })), + [data] + ); + + const cards = useMemo(() => { + if (!data) return []; + const { overview, responseTimes, satisfaction } = data; + const cur = overview.current; + const prev = overview.previous; + const rt = responseTimes.current; + const sat = satisfaction.current; + const satPrev = satisfaction.previous; + + const rfrPct = rt.resolution.count > 0 + ? Math.round((rt.resolvedFirstReply * 100) / rt.resolution.count) + : null; + + return [ + { + key: "new", + title: "New Conversations", + value: formatNumber(cur.newConversations), + subtitle: `${formatNumber(cur.messagesReceived)} messages received`, + trend: trendPct(cur.newConversations, prev.newConversations), + moreIsBetter: true, + suffix: "%", + }, + { + key: "resolved", + title: "Resolved", + value: formatNumber(cur.resolved), + subtitle: rfrPct != null ? `${rfrPct}% on first reply` : undefined, + trend: trendPct(cur.resolved, prev.resolved), + moreIsBetter: true, + suffix: "%", + }, + { + key: "frt", + title: "Median First Response", + value: formatSeconds(rt.firstResponse.medianSec), + subtitle: `avg ${formatSeconds(rt.firstResponse.avgSec)}`, + // No trend pill: chat + workflow auto-replies produce sub-minute medians + // that make percent deltas absurd (e.g. 2s → 5m reads as +14,850%) + trend: null, + moreIsBetter: false, + suffix: "%", + }, + { + key: "satisfaction", + title: "Satisfaction", + value: sat.score != null ? `${sat.score}%` : "—", + subtitle: sat.total > 0 + ? `${formatNumber(sat.total)} ratings · ${sat.great} great · ${sat.bad} bad` + : "No ratings in period", + trend: + sat.score != null && satPrev.score != null ? sat.score - satPrev.score : null, + moreIsBetter: true, + suffix: " pts", + }, + ]; + }, [data]); + + const hasData = (data?.overview.current.newConversations ?? 0) > 0 + || (data?.overview.current.messagesReceived ?? 0) > 0; + + const headerActions = !error ? ( + + ) : null; + + return ( + + + + + {!error && ( + loading ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ) : ( + cards.length > 0 && ( +
+ {cards.map((card) => ( + + ))} +
+ ) + ) + )} + + {!error && !loading && ( +
+ {(Object.keys(SERIES_LABELS) as ChartSeriesKey[]).map((key) => ( + toggleMetric(key)} + > + {SERIES_LABELS[key]} + + ))} +
+ )} + + {loading ? ( +
+
+ +
+
+
+
+
+ ) : error ? ( + + ) : !hasData ? ( + + ) : ( + <> +
+
+ {!hasActiveMetrics ? ( + + ) : ( + + + + + formatNumber(value)} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + allowDecimals={false} + /> + } /> + {metrics.newConversations && ( + + )} + {metrics.resolved && ( + + )} + {metrics.messages && ( + + )} + + + )} +
+
+ +
+
+ + + + + + )} + + + ); +}; + +const ConversationsTooltip = ({ active, payload, label }: TooltipProps) => { + if (!active || !payload?.length) return null; + + return ( +
+

{label}

+
+ {payload.map((entry, index) => ( +
+
+ + {entry.name} +
+ + {entry.value != null ? formatNumber(entry.value as number) : "—"} + +
+ ))} +
+
+ ); +}; + +// How-fast-do-we-answer histogram over FreeScout's report buckets. Single-series, +// so no legend; direct count labels on the bars carry the values. +function FrtHistogram({ + buckets, + medianSec, +}: { + buckets: FrtBucket[]; + medianSec: number | null; +}) { + const total = buckets.reduce((sum, b) => sum + b.count, 0); + if (total === 0) return null; + + return ( +
+
+ + First Response Time + + + median {formatSeconds(medianSec)} · {formatNumber(total)} conversations + +
+
+ + + + + + (value > 0 ? formatNumber(value) : "")} + style={{ fill: "#7c7870", fontSize: 10 }} + /> + + + +
+
+ ); +} + +const TABLE_HEAD_CLASS = + "h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]"; + +function AgentLeaderboard({ agents }: { agents: AgentEntry[] }) { + if (agents.length === 0) { + return ( +
+

No agent activity

+
+ ); + } + + return ( +
+
+ + + + + Agent + Replies + Closed + Helped + CSAT + + + + {agents.map((agent, index) => ( + + + {index + 1} + + + {agent.name} + + + {formatNumber(agent.replies)} + + + {agent.closed > 0 ? formatNumber(agent.closed) : "—"} + + + {agent.customersHelped > 0 ? formatNumber(agent.customersHelped) : "—"} + + + {agent.satScore != null ? ( + = 80 + ? "text-emerald-600 font-medium" + : agent.satScore >= 50 + ? "text-amber-600 font-medium" + : "text-red-600 font-medium" + } + > + {agent.satScore}% + + ) : ( + + )} + + + ))} + +
+
+
+ ); +} + +function SecondaryStats({ data }: { data: FreescoutReport }) { + const { overview, responseTimes, channels, phone, range } = data; + const cur = overview.current; + const totalChannels = channels.email + channels.chat + channels.phone + channels.other; + const pct = (n: number) => + totalChannels > 0 ? `${Math.round((n * 100) / totalChannels)}%` : "0%"; + + const phoneCur = phone?.current ?? null; + const phonePrev = phone?.previous ?? null; + + return ( +
+
+ + + + + + +
+ + {phoneCur && ( +
+ + + + +
+ )} +
+ ); +} + +export default CustomerServiceDashboard; diff --git a/inventory/src/components/dashboard/FinancialOverview.tsx b/inventory/src/components/dashboard/FinancialOverview.tsx index f9c8220..a9d8972 100644 --- a/inventory/src/components/dashboard/FinancialOverview.tsx +++ b/inventory/src/components/dashboard/FinancialOverview.tsx @@ -128,8 +128,8 @@ type ChartPoint = { // Chart colors from the semantic FINANCIAL_COLORS tokens (Sorbet Studio) const chartColors: Record = { income: FINANCIAL_COLORS.income, // Coral - revenue/income streams - cogs: FINANCIAL_COLORS.expense, // Warm amber - costs/expenses - cogsPercentage: "#c9973d", // Lighter amber for the percentage line + cogs: FINANCIAL_COLORS.expense, // Studio amber - costs/expenses + cogsPercentage: FINANCIAL_COLORS.expense, // Same amber — the dashed stroke carries the distinction profit: FINANCIAL_COLORS.profit, // Teal - profit metrics margin: FINANCIAL_COLORS.margin, // Violet - percentage/derived metrics }; @@ -1268,11 +1268,18 @@ const FinancialOverview = () => { > {series.label} diff --git a/inventory/src/components/dashboard/Navigation.jsx b/inventory/src/components/dashboard/Navigation.jsx index c1b2878..4f4a3d4 100644 --- a/inventory/src/components/dashboard/Navigation.jsx +++ b/inventory/src/components/dashboard/Navigation.jsx @@ -18,6 +18,7 @@ const ALL_SECTIONS = [ { id: "user-behavior", label: "Behavior", permission: "dashboard:user_behavior" }, { id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" }, { id: "typeform", label: "Surveys", permission: "dashboard:typeform" }, + { id: "customer-service", label: "Support", permission: "dashboard:customer_service" }, ]; const Navigation = () => { diff --git a/inventory/src/components/dashboard/OperationsMetrics.tsx b/inventory/src/components/dashboard/OperationsMetrics.tsx index c3fa756..60cdedc 100644 --- a/inventory/src/components/dashboard/OperationsMetrics.tsx +++ b/inventory/src/components/dashboard/OperationsMetrics.tsx @@ -32,7 +32,7 @@ import PeriodSelectionPopover, { type QuickPreset, } from "@/components/dashboard/PeriodSelectionPopover"; import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod"; -import { CARD_STYLES } from "@/lib/dashboard/designTokens"; +import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens"; import { DashboardSectionHeader, DashboardStatCard, @@ -41,7 +41,6 @@ import { DashboardEmptyState, DashboardErrorState, TOOLTIP_STYLES, - METRIC_COLORS, MetricPill, PILL_TRIGGER_CLASS, } from "@/components/dashboard/shared"; @@ -126,11 +125,13 @@ type ChartPoint = { tooltipLabel: string; }; +// Sorbet Studio quad — pieces series stay dashed so hue is never the only +// separator between the picked/shipped pairs const chartColors: Record = { - ordersPicked: METRIC_COLORS.orders, - piecesPicked: METRIC_COLORS.aov, - ordersShipped: METRIC_COLORS.profit, - piecesShipped: METRIC_COLORS.secondary, + ordersPicked: STUDIO_COLORS.violet, + piecesPicked: STUDIO_COLORS.amber, + ordersShipped: STUDIO_COLORS.teal, + piecesShipped: STUDIO_COLORS.coral, }; const SERIES_LABELS: Record = { diff --git a/inventory/src/components/dashboard/PayrollMetrics.tsx b/inventory/src/components/dashboard/PayrollMetrics.tsx index b0b2aaf..45e35a8 100644 --- a/inventory/src/components/dashboard/PayrollMetrics.tsx +++ b/inventory/src/components/dashboard/PayrollMetrics.tsx @@ -22,6 +22,7 @@ import { CartesianGrid, Line, ComposedChart, + Rectangle, ResponsiveContainer, Tooltip, XAxis, @@ -29,7 +30,7 @@ import { } from "recharts"; import type { TooltipProps } from "recharts"; import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar, TrendingUp } from "lucide-react"; -import { CARD_STYLES } from "@/lib/dashboard/designTokens"; +import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens"; import { DashboardSectionHeader, DashboardStatCard, @@ -38,7 +39,6 @@ import { DashboardEmptyState, DashboardErrorState, TOOLTIP_STYLES, - METRIC_COLORS, LegendChip, PILL_TRIGGER_CLASS, } from "@/components/dashboard/shared"; @@ -124,13 +124,24 @@ const PERIOD_COUNT_OPTIONS: { value: PeriodCountOption; label: string }[] = [ { value: 12, label: "12 periods" }, ]; +// Sorbet Studio hues: violet+amber stacked segments (amber doubles as the +// "hot" overtime tone), teal FTE line on the right axis. `hours` was unused. const chartColors = { - regular: METRIC_COLORS.orders, - overtime: METRIC_COLORS.expense, - hours: METRIC_COLORS.profit, - fte: METRIC_COLORS.secondary, + regular: STUDIO_COLORS.violet, + overtime: STUDIO_COLORS.amber, + fte: STUDIO_COLORS.teal, }; +// Rounds only the segment that tops the stack: overtime when present, +// otherwise regular (a fixed radius on both would notch every boundary). +const BAR_RADIUS: [number, number, number, number] = [6, 6, 0, 0]; + +const OvertimeBarShape = (props: any) => ; + +const RegularBarShape = (props: any) => ( + 0 ? undefined : BAR_RADIUS} /> +); + const formatNumber = (value: number, decimals = 0) => { if (!Number.isFinite(value)) return "0"; return value.toLocaleString("en-US", { @@ -576,6 +587,7 @@ const PayrollMetrics = () => { name="Regular Hours" stackId="hours" fill={chartColors.regular} + shape={RegularBarShape} /> { name="Overtime" stackId="hours" fill={chartColors.overtime} + shape={OvertimeBarShape} /> + +
+ +
+
diff --git a/inventory/tsconfig.tsbuildinfo b/inventory/tsconfig.tsbuildinfo index e750fa0..7397370 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/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/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/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/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 diff --git a/inventory/vite.config.ts b/inventory/vite.config.ts index b914c3f..aceff7c 100644 --- a/inventory/vite.config.ts +++ b/inventory/vite.config.ts @@ -115,6 +115,12 @@ export default defineConfig(({ mode }) => { secure: false, rewrite: (path) => path, }, + "/api/freescout": { + target: "https://tools.acherryontop.com", + changeOrigin: true, + secure: false, + rewrite: (path) => path, + }, "/api/acot": { target: "https://tools.acherryontop.com", changeOrigin: true,