Add customer service dashboard component
This commit is contained in:
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user