Add customer service dashboard component

This commit is contained in:
2026-07-18 12:09:56 -04:00
parent e9894c893b
commit cde8148196
14 changed files with 1190 additions and 24 deletions
@@ -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;
}
@@ -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();
}
+28
View File
@@ -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'));
@@ -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),
};
}
}
@@ -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<T> = { 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<OverviewStats>;
responseTimes: PeriodPair<ResponseTimeStats>;
satisfaction: PeriodPair<SatisfactionStats>;
timeseries: TimeseriesPoint[];
channels: { email: number; phone: number; chat: number; other: number };
agents: AgentEntry[];
frtDistribution: FrtBucket[];
phone: PeriodPair<PhoneStats> | 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<ChartSeriesKey, string> = {
newConversations: STUDIO_COLORS.teal,
resolved: STUDIO_COLORS.violet,
messages: STUDIO_COLORS.amber,
};
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
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<FreescoutReport | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
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<FreescoutReport>("/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 ? (
<BusinessRangeSelect value={days} onValueChange={setDays} options={RANGE_OPTIONS} />
) : null;
return (
<Card className={`w-full h-full ${CARD_STYLES.elevated}`}>
<DashboardSectionHeader title="Customer Service" size="large" actions={headerActions} />
<CardContent className="p-4 pt-3 space-y-3">
{!error && (
loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
{Array.from({ length: 4 }).map((_, index) => (
<DashboardStatCardSkeleton key={index} hasSubtitle />
))}
</div>
) : (
cards.length > 0 && (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
{cards.map((card) => (
<DashboardStatCard
key={card.key}
title={card.title}
value={card.value}
subtitle={card.subtitle}
trend={
card.trend != null && Number.isFinite(card.trend)
? {
value: card.trend,
moreIsBetter: card.moreIsBetter,
suffix: card.suffix,
}
: undefined
}
/>
))}
</div>
)
)
)}
{!error && !loading && (
<div className="flex flex-wrap items-center gap-0.5">
{(Object.keys(SERIES_LABELS) as ChartSeriesKey[]).map((key) => (
<MetricPill
key={key}
active={metrics[key]}
color={chartColors[key]}
onClick={() => toggleMetric(key)}
>
{SERIES_LABELS[key]}
</MetricPill>
))}
</div>
)}
{loading ? (
<div className="flex flex-col lg:flex-row gap-3">
<div className="w-full lg:w-[55%]">
<ChartSkeleton type="area" height="md" withCard={false} />
</div>
<div className="w-full lg:w-[45%]">
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] animate-pulse" />
</div>
</div>
) : error ? (
<DashboardErrorState
error={`Failed to load customer service data: ${error}`}
className="mx-0 my-0"
/>
) : !hasData ? (
<DashboardEmptyState
icon={MessagesSquare}
title="No conversation data"
description="Try selecting a different time range"
/>
) : (
<>
<div className="flex flex-col lg:flex-row gap-3">
<div className={`h-[280px] w-full lg:w-[55%] ${CARD_STYLES.subtle} p-0 relative`}>
{!hasActiveMetrics ? (
<DashboardEmptyState
icon={TrendingUp}
title="No metrics selected"
description="Select at least one metric to visualize."
/>
) : (
<ResponsiveContainer width="100%" height="100%">
<ComposedChart
data={chartData}
margin={{ top: 8, right: 0, left: -8, bottom: 0 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="label"
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
minTickGap={24}
/>
<YAxis
tickFormatter={(value: number) => formatNumber(value)}
className="text-xs text-muted-foreground"
tick={{ fill: "currentColor" }}
allowDecimals={false}
/>
<Tooltip content={<ConversationsTooltip />} />
{metrics.newConversations && (
<Bar
dataKey="newConversations"
name={SERIES_LABELS.newConversations}
fill={chartColors.newConversations}
fillOpacity={0.75}
radius={[6, 6, 0, 0]}
maxBarSize={18}
/>
)}
{metrics.resolved && (
<Line
type="monotone"
dataKey="resolved"
name={SERIES_LABELS.resolved}
stroke={chartColors.resolved}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
/>
)}
{metrics.messages && (
<Line
type="monotone"
dataKey="messages"
name={SERIES_LABELS.messages}
stroke={chartColors.messages}
strokeWidth={2}
strokeDasharray="5 3"
dot={false}
activeDot={{ r: 4 }}
/>
)}
</ComposedChart>
</ResponsiveContainer>
)}
</div>
<div className="w-full lg:w-[45%]">
<AgentLeaderboard agents={data?.agents ?? []} />
</div>
</div>
<FrtHistogram
buckets={data?.frtDistribution ?? []}
medianSec={data?.responseTimes.current.firstResponse.medianSec ?? null}
/>
<SecondaryStats data={data!} />
</>
)}
</CardContent>
</Card>
);
};
const ConversationsTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
if (!active || !payload?.length) return null;
return (
<div className={TOOLTIP_STYLES.container}>
<p className={TOOLTIP_STYLES.header}>{label}</p>
<div className={TOOLTIP_STYLES.content}>
{payload.map((entry, index) => (
<div key={index} className={TOOLTIP_STYLES.row}>
<div className={TOOLTIP_STYLES.rowLabel}>
<span
className={TOOLTIP_STYLES.dot}
style={{ backgroundColor: entry.stroke || entry.color || "#888" }}
/>
<span className={TOOLTIP_STYLES.name}>{entry.name}</span>
</div>
<span className={TOOLTIP_STYLES.value}>
{entry.value != null ? formatNumber(entry.value as number) : "—"}
</span>
</div>
))}
</div>
</div>
);
};
// 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 (
<div className={`${CARD_STYLES.subtle} px-3 pb-1 pt-2.5`}>
<div className="flex items-baseline justify-between px-1">
<span className="text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">
First Response Time
</span>
<span className="text-[10.5px] text-[#7c7870]">
median {formatSeconds(medianSec)} · {formatNumber(total)} conversations
</span>
</div>
<div className="h-[120px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={buckets} margin={{ top: 18, right: 4, left: 4, bottom: 0 }}>
<XAxis
dataKey="bucket"
interval={0}
tickLine={false}
axisLine={false}
tick={{ fill: "#7c7870", fontSize: 10 }}
/>
<YAxis hide />
<Bar
dataKey="count"
fill={STUDIO_COLORS.violet}
fillOpacity={0.8}
radius={[6, 6, 0, 0]}
maxBarSize={44}
minPointSize={3}
>
<LabelList
dataKey="count"
position="top"
formatter={(value: number) => (value > 0 ? formatNumber(value) : "")}
style={{ fill: "#7c7870", fontSize: 10 }}
/>
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
}
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 (
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] flex items-center justify-center">
<p className="text-sm text-muted-foreground">No agent activity</p>
</div>
);
}
return (
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto">
<Table>
<TableHeader className="sticky top-0 z-10 bg-[#faf9f7]">
<TableRow className="hover:bg-transparent">
<TableHead className={`${TABLE_HEAD_CLASS} w-8`} />
<TableHead className={TABLE_HEAD_CLASS}>Agent</TableHead>
<TableHead className={`${TABLE_HEAD_CLASS} text-right`}>Replies</TableHead>
<TableHead className={`${TABLE_HEAD_CLASS} text-right`}>Closed</TableHead>
<TableHead className={`${TABLE_HEAD_CLASS} text-right`}>Helped</TableHead>
<TableHead className={`${TABLE_HEAD_CLASS} text-right`}>CSAT</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{agents.map((agent, index) => (
<TableRow key={agent.id}>
<TableCell className="py-1.5 px-2 w-8 text-xs text-muted-foreground text-center">
{index + 1}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs font-medium truncate max-w-[110px]">
{agent.name}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right font-medium">
{formatNumber(agent.replies)}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{agent.closed > 0 ? formatNumber(agent.closed) : "—"}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right text-muted-foreground">
{agent.customersHelped > 0 ? formatNumber(agent.customersHelped) : "—"}
</TableCell>
<TableCell className="py-1.5 px-2 text-xs text-right">
{agent.satScore != null ? (
<span
className={
agent.satScore >= 80
? "text-emerald-600 font-medium"
: agent.satScore >= 50
? "text-amber-600 font-medium"
: "text-red-600 font-medium"
}
>
{agent.satScore}%
</span>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
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 (
<div className="space-y-2">
<div className={`${QUIET_STRIP_CLASS} grid-cols-2 sm:grid-cols-3 lg:grid-cols-6`}>
<QuietStat label="Replies Sent" value={formatNumber(cur.repliesSent)} />
<QuietStat label="Customers Helped" value={formatNumber(cur.customersHelped)} />
<QuietStat
label="Median Resolution"
value={formatSeconds(responseTimes.current.resolution.medianSec)}
sub={`avg ${formatSeconds(responseTimes.current.resolution.avgSec)}`}
/>
<QuietStat label="Email" value={formatNumber(channels.email)} sub={pct(channels.email)} />
<QuietStat label="Chat" value={formatNumber(channels.chat)} sub={pct(channels.chat)} />
<QuietStat label="Phone" value={formatNumber(channels.phone)} sub={pct(channels.phone)} />
</div>
{phoneCur && (
<div className={`${QUIET_STRIP_CLASS} grid-cols-2 sm:grid-cols-4`}>
<QuietStat
label="Calls"
value={formatNumber(phoneCur.total)}
sub={`${formatNumber(phoneCur.inbound)} in · ${formatNumber(phoneCur.outbound)} out`}
/>
<QuietStat
label="Calls / Day"
value={(phoneCur.total / range.days).toFixed(1)}
sub={phonePrev ? `${(phonePrev.total / range.days).toFixed(1)} prior period` : undefined}
/>
<QuietStat label="Avg Call" value={formatSeconds(phoneCur.avgDurationSec)} />
<QuietStat label="Voicemails" value={formatNumber(phoneCur.voicemails)} />
</div>
)}
</div>
);
}
export default CustomerServiceDashboard;
@@ -128,8 +128,8 @@ type ChartPoint = {
// Chart colors from the semantic FINANCIAL_COLORS tokens (Sorbet Studio)
const chartColors: Record<ChartSeriesKey, string> = {
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 = () => {
>
<span
className="inline-block h-2 w-2 rounded-full"
style={{
style={
series.key === "cogsPercentage"
? {
background: "transparent",
border: `1.5px dashed ${metrics[series.key] ? chartColors.cogsPercentage : "#d8d4cd"}`,
}
: {
background: metrics[series.key]
? chartColors[series.key as ChartSeriesKey]
: "#d8d4cd",
}}
}
}
/>
{series.label}
</button>
@@ -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 = () => {
@@ -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<ChartSeriesKey, string> = {
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<ChartSeriesKey, string> = {
@@ -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) => <Rectangle {...props} radius={BAR_RADIUS} />;
const RegularBarShape = (props: any) => (
<Rectangle {...props} radius={props.payload?.overtime > 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}
/>
<Bar
yAxisId="hours"
@@ -583,6 +595,7 @@ const PayrollMetrics = () => {
name="Overtime"
stackId="hours"
fill={chartColors.overtime}
shape={OvertimeBarShape}
/>
<Line
yAxisId="fte"
+2 -2
View File
@@ -68,7 +68,7 @@
--chart-orders: 246.9 48.1% 57.6%; /* #6b5fc7 - Studio violet */
--chart-aov: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
--chart-comparison: 168.8 81.0% 31.0%; /* #0f8f77 - Studio teal */
--chart-expense: 38.2 58.4% 44.3%; /* #b3832f - warm amber */
--chart-expense: 41.2 84.8% 31.0%; /* #92680c - dark gold */
--chart-profit: 147.8 51.3% 37.1%; /* #2e8f5b - green */
--chart-secondary: 205.4 46.8% 46.5%; /* #3f7fae - slate blue */
--chart-tertiary: 340.8 53.0% 54.9%; /* #c94f76 - berry */
@@ -137,7 +137,7 @@
--chart-orders: 246.9 48.1% 57.6%; /* #6b5fc7 - Studio violet */
--chart-aov: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
--chart-comparison: 168.8 81.0% 31.0%; /* #0f8f77 - Studio teal */
--chart-expense: 38.2 58.4% 44.3%; /* #b3832f - warm amber */
--chart-expense: 41.2 84.8% 31.0%; /* #92680c - dark gold */
--chart-profit: 147.8 51.3% 37.1%; /* #2e8f5b - green */
--chart-secondary: 205.4 46.8% 46.5%; /* #3f7fae - slate blue */
--chart-tertiary: 340.8 53.0% 54.9%; /* #c94f76 - berry */
+2 -2
View File
@@ -193,7 +193,7 @@ export const EVENT_COLORS = {
*/
export const FINANCIAL_COLORS = {
income: "#e06a4e", // Coral - Revenue streams (Studio primary)
expense: "#b3832f", // Warm amber - Costs/Expenses (COGS)
expense: "#92680c", // Dark gold - Costs/Expenses (COGS); deeper than the AOV amber, validated vs coral
profit: "#0f8f77", // Teal - Positive financial outcome
margin: "#6b5fc7", // Violet - Percentage metrics
} as const;
@@ -212,7 +212,7 @@ export const METRIC_COLORS = {
orders: "#6b5fc7", // Studio violet - count/volume metrics
aov: "#a87a24", // Studio amber - calculated/derived metrics
comparison: "#0f8f77", // Studio teal - previous period (always dashed)
expense: "#b3832f", // Warm amber - costs/expenses
expense: "#92680c", // Dark gold - costs/expenses (matches FINANCIAL_COLORS.expense)
profit: "#2e8f5b", // Green - profit metrics
secondary: "#3f7fae", // Slate blue - secondary metrics
tertiary: "#c94f76", // Berry - tertiary metrics
+6
View File
@@ -12,6 +12,7 @@ import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
import { RealtimeChip } from "@/components/dashboard/RealtimeAnalytics";
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
import CustomerServiceDashboard from "@/components/dashboard/CustomerServiceDashboard";
import PayrollMetrics from "@/components/dashboard/PayrollMetrics";
import OperationsMetrics from "@/components/dashboard/OperationsMetrics";
import Header from "@/components/dashboard/Header";
@@ -120,6 +121,11 @@ export function Dashboard() {
<TypeformDashboard />
</section>
</Protected>
<Protected permission="dashboard:customer_service">
<section id="customer-service">
<CustomerServiceDashboard />
</section>
</Protected>
</main>
</div>
</div>
File diff suppressed because one or more lines are too long
+6
View File
@@ -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,