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/meta/* → routes/meta (was meta-server :3005)
|
||||||
// /api/dashboard-analytics/* → routes/google (was google-server :3007 via Caddy /api/analytics rewrite)
|
// /api/dashboard-analytics/* → routes/google (was google-server :3007 via Caddy /api/analytics rewrite)
|
||||||
// /api/typeform/* → routes/typeform (was typeform-server :3008)
|
// /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 infrastructure (Phase 2 + Phase 6):
|
||||||
// - shared/auth/middleware.js authenticate() guards /api/* (Phase 6.1/6.2 — second line of defense)
|
// - 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 { createMetaRouter } from './routes/meta/index.js';
|
||||||
import { createGoogleRouter } from './routes/google/index.js';
|
import { createGoogleRouter } from './routes/google/index.js';
|
||||||
import { createTypeformRouter } from './routes/typeform/index.js';
|
import { createTypeformRouter } from './routes/typeform/index.js';
|
||||||
|
import { createFreescoutRouter } from './routes/freescout/index.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
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.
|
// if Redis is temporarily unavailable, and aligns with shared/db/redis.js defaults.
|
||||||
const redis = createRedis();
|
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(requestLog());
|
||||||
app.use(cors(corsOptions));
|
app.use(cors(corsOptions));
|
||||||
app.use(express.json({ limit: '10mb' }));
|
app.use(express.json({ limit: '10mb' }));
|
||||||
@@ -91,6 +112,11 @@ app.use('/api/meta', createMetaRouter());
|
|||||||
// Caddy can drop the rewrite — see Caddyfile.proposed.
|
// Caddy can drop the rewrite — see Caddyfile.proposed.
|
||||||
app.use('/api/dashboard-analytics', createGoogleRouter({ redis }));
|
app.use('/api/dashboard-analytics', createGoogleRouter({ redis }));
|
||||||
app.use('/api/typeform', createTypeformRouter({ 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) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({
|
res.json({
|
||||||
@@ -118,6 +144,8 @@ const shutdown = async (signal) => {
|
|||||||
server.close();
|
server.close();
|
||||||
try { await redis.quit(); } catch { /* ignore */ }
|
try { await redis.quit(); } catch { /* ignore */ }
|
||||||
try { await pool.end(); } catch { /* ignore */ }
|
try { await pool.end(); } catch { /* ignore */ }
|
||||||
|
try { await freescoutPool?.end(); } catch { /* ignore */ }
|
||||||
|
try { await phonePool?.end(); } catch { /* ignore */ }
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
};
|
};
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
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)
|
// Chart colors from the semantic FINANCIAL_COLORS tokens (Sorbet Studio)
|
||||||
const chartColors: Record<ChartSeriesKey, string> = {
|
const chartColors: Record<ChartSeriesKey, string> = {
|
||||||
income: FINANCIAL_COLORS.income, // Coral - revenue/income streams
|
income: FINANCIAL_COLORS.income, // Coral - revenue/income streams
|
||||||
cogs: FINANCIAL_COLORS.expense, // Warm amber - costs/expenses
|
cogs: FINANCIAL_COLORS.expense, // Studio amber - costs/expenses
|
||||||
cogsPercentage: "#c9973d", // Lighter amber for the percentage line
|
cogsPercentage: FINANCIAL_COLORS.expense, // Same amber — the dashed stroke carries the distinction
|
||||||
profit: FINANCIAL_COLORS.profit, // Teal - profit metrics
|
profit: FINANCIAL_COLORS.profit, // Teal - profit metrics
|
||||||
margin: FINANCIAL_COLORS.margin, // Violet - percentage/derived metrics
|
margin: FINANCIAL_COLORS.margin, // Violet - percentage/derived metrics
|
||||||
};
|
};
|
||||||
@@ -1268,11 +1268,18 @@ const FinancialOverview = () => {
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="inline-block h-2 w-2 rounded-full"
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
style={{
|
style={
|
||||||
background: metrics[series.key]
|
series.key === "cogsPercentage"
|
||||||
? chartColors[series.key as ChartSeriesKey]
|
? {
|
||||||
: "#d8d4cd",
|
background: "transparent",
|
||||||
}}
|
border: `1.5px dashed ${metrics[series.key] ? chartColors.cogsPercentage : "#d8d4cd"}`,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
background: metrics[series.key]
|
||||||
|
? chartColors[series.key as ChartSeriesKey]
|
||||||
|
: "#d8d4cd",
|
||||||
|
}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{series.label}
|
{series.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const ALL_SECTIONS = [
|
|||||||
{ id: "user-behavior", label: "Behavior", permission: "dashboard:user_behavior" },
|
{ id: "user-behavior", label: "Behavior", permission: "dashboard:user_behavior" },
|
||||||
{ id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" },
|
{ id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" },
|
||||||
{ id: "typeform", label: "Surveys", permission: "dashboard:typeform" },
|
{ id: "typeform", label: "Surveys", permission: "dashboard:typeform" },
|
||||||
|
{ id: "customer-service", label: "Support", permission: "dashboard:customer_service" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const Navigation = () => {
|
const Navigation = () => {
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import PeriodSelectionPopover, {
|
|||||||
type QuickPreset,
|
type QuickPreset,
|
||||||
} from "@/components/dashboard/PeriodSelectionPopover";
|
} from "@/components/dashboard/PeriodSelectionPopover";
|
||||||
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
|
import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod";
|
||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
import {
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
@@ -41,7 +41,6 @@ import {
|
|||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
METRIC_COLORS,
|
|
||||||
MetricPill,
|
MetricPill,
|
||||||
PILL_TRIGGER_CLASS,
|
PILL_TRIGGER_CLASS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
@@ -126,11 +125,13 @@ type ChartPoint = {
|
|||||||
tooltipLabel: string;
|
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> = {
|
const chartColors: Record<ChartSeriesKey, string> = {
|
||||||
ordersPicked: METRIC_COLORS.orders,
|
ordersPicked: STUDIO_COLORS.violet,
|
||||||
piecesPicked: METRIC_COLORS.aov,
|
piecesPicked: STUDIO_COLORS.amber,
|
||||||
ordersShipped: METRIC_COLORS.profit,
|
ordersShipped: STUDIO_COLORS.teal,
|
||||||
piecesShipped: METRIC_COLORS.secondary,
|
piecesShipped: STUDIO_COLORS.coral,
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
|
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Line,
|
Line,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
|
Rectangle,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -29,7 +30,7 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import type { TooltipProps } from "recharts";
|
import type { TooltipProps } from "recharts";
|
||||||
import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar, TrendingUp } from "lucide-react";
|
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 {
|
import {
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
@@ -38,7 +39,6 @@ import {
|
|||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
METRIC_COLORS,
|
|
||||||
LegendChip,
|
LegendChip,
|
||||||
PILL_TRIGGER_CLASS,
|
PILL_TRIGGER_CLASS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
@@ -124,13 +124,24 @@ const PERIOD_COUNT_OPTIONS: { value: PeriodCountOption; label: string }[] = [
|
|||||||
{ value: 12, label: "12 periods" },
|
{ 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 = {
|
const chartColors = {
|
||||||
regular: METRIC_COLORS.orders,
|
regular: STUDIO_COLORS.violet,
|
||||||
overtime: METRIC_COLORS.expense,
|
overtime: STUDIO_COLORS.amber,
|
||||||
hours: METRIC_COLORS.profit,
|
fte: STUDIO_COLORS.teal,
|
||||||
fte: METRIC_COLORS.secondary,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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) => {
|
const formatNumber = (value: number, decimals = 0) => {
|
||||||
if (!Number.isFinite(value)) return "0";
|
if (!Number.isFinite(value)) return "0";
|
||||||
return value.toLocaleString("en-US", {
|
return value.toLocaleString("en-US", {
|
||||||
@@ -576,6 +587,7 @@ const PayrollMetrics = () => {
|
|||||||
name="Regular Hours"
|
name="Regular Hours"
|
||||||
stackId="hours"
|
stackId="hours"
|
||||||
fill={chartColors.regular}
|
fill={chartColors.regular}
|
||||||
|
shape={RegularBarShape}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
yAxisId="hours"
|
yAxisId="hours"
|
||||||
@@ -583,6 +595,7 @@ const PayrollMetrics = () => {
|
|||||||
name="Overtime"
|
name="Overtime"
|
||||||
stackId="hours"
|
stackId="hours"
|
||||||
fill={chartColors.overtime}
|
fill={chartColors.overtime}
|
||||||
|
shape={OvertimeBarShape}
|
||||||
/>
|
/>
|
||||||
<Line
|
<Line
|
||||||
yAxisId="fte"
|
yAxisId="fte"
|
||||||
|
|||||||
@@ -68,7 +68,7 @@
|
|||||||
--chart-orders: 246.9 48.1% 57.6%; /* #6b5fc7 - Studio violet */
|
--chart-orders: 246.9 48.1% 57.6%; /* #6b5fc7 - Studio violet */
|
||||||
--chart-aov: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
|
--chart-aov: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
|
||||||
--chart-comparison: 168.8 81.0% 31.0%; /* #0f8f77 - Studio teal */
|
--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-profit: 147.8 51.3% 37.1%; /* #2e8f5b - green */
|
||||||
--chart-secondary: 205.4 46.8% 46.5%; /* #3f7fae - slate blue */
|
--chart-secondary: 205.4 46.8% 46.5%; /* #3f7fae - slate blue */
|
||||||
--chart-tertiary: 340.8 53.0% 54.9%; /* #c94f76 - berry */
|
--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-orders: 246.9 48.1% 57.6%; /* #6b5fc7 - Studio violet */
|
||||||
--chart-aov: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
|
--chart-aov: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
|
||||||
--chart-comparison: 168.8 81.0% 31.0%; /* #0f8f77 - Studio teal */
|
--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-profit: 147.8 51.3% 37.1%; /* #2e8f5b - green */
|
||||||
--chart-secondary: 205.4 46.8% 46.5%; /* #3f7fae - slate blue */
|
--chart-secondary: 205.4 46.8% 46.5%; /* #3f7fae - slate blue */
|
||||||
--chart-tertiary: 340.8 53.0% 54.9%; /* #c94f76 - berry */
|
--chart-tertiary: 340.8 53.0% 54.9%; /* #c94f76 - berry */
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ export const EVENT_COLORS = {
|
|||||||
*/
|
*/
|
||||||
export const FINANCIAL_COLORS = {
|
export const FINANCIAL_COLORS = {
|
||||||
income: "#e06a4e", // Coral - Revenue streams (Studio primary)
|
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
|
profit: "#0f8f77", // Teal - Positive financial outcome
|
||||||
margin: "#6b5fc7", // Violet - Percentage metrics
|
margin: "#6b5fc7", // Violet - Percentage metrics
|
||||||
} as const;
|
} as const;
|
||||||
@@ -212,7 +212,7 @@ export const METRIC_COLORS = {
|
|||||||
orders: "#6b5fc7", // Studio violet - count/volume metrics
|
orders: "#6b5fc7", // Studio violet - count/volume metrics
|
||||||
aov: "#a87a24", // Studio amber - calculated/derived metrics
|
aov: "#a87a24", // Studio amber - calculated/derived metrics
|
||||||
comparison: "#0f8f77", // Studio teal - previous period (always dashed)
|
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
|
profit: "#2e8f5b", // Green - profit metrics
|
||||||
secondary: "#3f7fae", // Slate blue - secondary metrics
|
secondary: "#3f7fae", // Slate blue - secondary metrics
|
||||||
tertiary: "#c94f76", // Berry - tertiary metrics
|
tertiary: "#c94f76", // Berry - tertiary metrics
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
|
|||||||
import { RealtimeChip } from "@/components/dashboard/RealtimeAnalytics";
|
import { RealtimeChip } from "@/components/dashboard/RealtimeAnalytics";
|
||||||
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
||||||
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
||||||
|
import CustomerServiceDashboard from "@/components/dashboard/CustomerServiceDashboard";
|
||||||
import PayrollMetrics from "@/components/dashboard/PayrollMetrics";
|
import PayrollMetrics from "@/components/dashboard/PayrollMetrics";
|
||||||
import OperationsMetrics from "@/components/dashboard/OperationsMetrics";
|
import OperationsMetrics from "@/components/dashboard/OperationsMetrics";
|
||||||
import Header from "@/components/dashboard/Header";
|
import Header from "@/components/dashboard/Header";
|
||||||
@@ -120,6 +121,11 @@ export function Dashboard() {
|
|||||||
<TypeformDashboard />
|
<TypeformDashboard />
|
||||||
</section>
|
</section>
|
||||||
</Protected>
|
</Protected>
|
||||||
|
<Protected permission="dashboard:customer_service">
|
||||||
|
<section id="customer-service">
|
||||||
|
<CustomerServiceDashboard />
|
||||||
|
</section>
|
||||||
|
</Protected>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -115,6 +115,12 @@ export default defineConfig(({ mode }) => {
|
|||||||
secure: false,
|
secure: false,
|
||||||
rewrite: (path) => path,
|
rewrite: (path) => path,
|
||||||
},
|
},
|
||||||
|
"/api/freescout": {
|
||||||
|
target: "https://tools.acherryontop.com",
|
||||||
|
changeOrigin: true,
|
||||||
|
secure: false,
|
||||||
|
rewrite: (path) => path,
|
||||||
|
},
|
||||||
"/api/acot": {
|
"/api/acot": {
|
||||||
target: "https://tools.acherryontop.com",
|
target: "https://tools.acherryontop.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in New Issue
Block a user