Compare commits
6 Commits
54a2460eac
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e4a09ee3f | |||
| 6c973f0a9d | |||
| d779228485 | |||
| cde8148196 | |||
| e9894c893b | |||
| 9bdde05d9d |
@@ -12,13 +12,20 @@ export function createAuthRoutes({ pool }) {
|
|||||||
// shared/auth/middleware.js and is used by downstream services. Auth-server's surface is
|
// shared/auth/middleware.js and is used by downstream services. Auth-server's surface is
|
||||||
// small enough that a local copy is fine; the security boundary is the JWT verify step.
|
// small enough that a local copy is fine; the security boundary is the JWT verify step.
|
||||||
async function authenticate(req, res, next) {
|
async function authenticate(req, res, next) {
|
||||||
|
const authHeader = req.headers.authorization;
|
||||||
|
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
const token = authHeader.split(' ')[1];
|
||||||
|
let decoded;
|
||||||
|
try {
|
||||||
|
decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
} catch (error) {
|
||||||
|
return res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
// DB failures are 500, not 401 — the frontend ends the session on 401,
|
||||||
|
// and a transient outage must not log users out.
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
||||||
return res.status(401).json({ error: 'Authentication required' });
|
|
||||||
}
|
|
||||||
const token = authHeader.split(' ')[1];
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1',
|
'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1',
|
||||||
[decoded.userId]
|
[decoded.userId]
|
||||||
@@ -29,7 +36,8 @@ export function createAuthRoutes({ pool }) {
|
|||||||
req.user = result.rows[0];
|
req.user = result.rows[0];
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(401).json({ error: 'Invalid token' });
|
console.error('Auth DB error:', error);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +66,7 @@ export function createAuthRoutes({ pool }) {
|
|||||||
const token = jwt.sign(
|
const token = jwt.sign(
|
||||||
{ userId: user.id, username: user.username },
|
{ userId: user.id, username: user.username },
|
||||||
process.env.JWT_SECRET,
|
process.env.JWT_SECRET,
|
||||||
{ expiresIn: '8h' }
|
{ expiresIn: '7d' }
|
||||||
);
|
);
|
||||||
const permissions = await getUserPermissions(user.id);
|
const permissions = await getUserPermissions(user.id);
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ logger.info({
|
|||||||
const app = express();
|
const app = express();
|
||||||
const port = Number(process.env.AUTH_PORT) || 3011;
|
const port = Number(process.env.AUTH_PORT) || 3011;
|
||||||
|
|
||||||
|
// Behind Caddy on localhost: without this, req.ip is ::1 for every request and
|
||||||
|
// the rate limiters put ALL users in one shared bucket (10 logins/15min for
|
||||||
|
// the whole office). Same setting as inventory-server's server.js.
|
||||||
|
app.set('trust proxy', 'loopback');
|
||||||
|
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
user: process.env.DB_USER,
|
user: process.env.DB_USER,
|
||||||
|
|||||||
@@ -38,26 +38,54 @@ const getImageUrls = (pid, iid = 1) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Main stats endpoint - replaces /api/klaviyo/events/stats
|
// The DB sits across an SSH tunnel (~190ms per round trip; the queries
|
||||||
router.get('/stats', async (req, res) => {
|
// themselves are ~5ms on the server), so /stats response time is dominated by
|
||||||
const startTime = Date.now();
|
// round-trip count, not query cost. Two mitigations:
|
||||||
console.log(`[STATS] Starting request for timeRange: ${req.query.timeRange}`);
|
// 1. computeStats runs its queries over several pooled connections (mysql2
|
||||||
|
// serializes per connection), capped so concurrent refreshes can't starve
|
||||||
// Set a timeout for the entire operation
|
// the 20-slot pool.
|
||||||
const timeoutPromise = new Promise((_, reject) => {
|
// 2. Responses are cached per param-set and served stale-while-revalidate:
|
||||||
setTimeout(() => reject(new Error('Request timeout after 15 seconds')), 15000);
|
// the dashboards poll every 30-60s, so they get the cached payload
|
||||||
});
|
// instantly while a single-flight background refresh keeps it current.
|
||||||
|
const STATS_QUERY_WIDTH = 4;
|
||||||
try {
|
const STATS_CACHE_TTL = 60 * 1000; // younger than this: serve as-is
|
||||||
const mainOperation = async () => {
|
const STATS_CACHE_MAX_STALE = 15 * 60 * 1000; // older than this: block on refresh
|
||||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
const statsCache = new Map(); // cacheKey -> { data, timestamp }
|
||||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
const statsInflight = new Map(); // cacheKey -> Promise<response>
|
||||||
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
|
|
||||||
const { connection, release } = await getDbConnection();
|
|
||||||
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
|
||||||
try {
|
|
||||||
|
|
||||||
|
const withTimeout = (promise, ms, label) => {
|
||||||
|
let timer;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => reject(new Error(`${label} timeout after ${ms / 1000} seconds`)), ms);
|
||||||
|
});
|
||||||
|
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Drain [name, fn] tasks with up to `width` workers, each holding its own
|
||||||
|
// pooled connection, so wall time is ~ceil(tasks / width) round trips. If a
|
||||||
|
// task rejects, remaining queued tasks are abandoned and every worker still
|
||||||
|
// releases its connection (mirrors the old single-connection failure mode).
|
||||||
|
async function runStatsTasks(tasks, width) {
|
||||||
|
const results = {};
|
||||||
|
const queue = [...tasks];
|
||||||
|
const worker = async () => {
|
||||||
|
const { connection, release } = await getDbConnection();
|
||||||
|
try {
|
||||||
|
let task;
|
||||||
|
while ((task = queue.shift()) !== undefined) {
|
||||||
|
results[task[0]] = await task[1](connection);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await Promise.all(Array.from({ length: Math.min(width, tasks.length) }, worker));
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function computeStats(timeRange, startDate, endDate, excludeCB) {
|
||||||
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
const isSingleDay = ['today', 'yesterday'].includes(timeRange);
|
||||||
|
|
||||||
// Main order stats query (optionally excludes Cherry Box orders)
|
// Main order stats query (optionally excludes Cherry Box orders)
|
||||||
// Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately
|
// Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately
|
||||||
@@ -75,9 +103,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [mainStats] = await connection.execute(mainStatsQuery, params);
|
|
||||||
const stats = mainStats[0];
|
|
||||||
|
|
||||||
// Cancelled orders query - uses date_cancelled instead of date_placed
|
// Cancelled orders query - uses date_cancelled instead of date_placed
|
||||||
// Shows orders cancelled during the selected period, regardless of when they were placed
|
// Shows orders cancelled during the selected period, regardless of when they were placed
|
||||||
const cancelledQuery = `
|
const cancelledQuery = `
|
||||||
@@ -90,9 +115,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
|
AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [cancelledResult] = await connection.execute(cancelledQuery, params);
|
|
||||||
const cancelledStats = cancelledResult[0] || { cancelledCount: 0, cancelledTotal: 0 };
|
|
||||||
|
|
||||||
// Refunds query (optionally excludes Cherry Box orders)
|
// Refunds query (optionally excludes Cherry Box orders)
|
||||||
const refundsQuery = `
|
const refundsQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -103,8 +125,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
|
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [refundStats] = await connection.execute(refundsQuery, params);
|
|
||||||
|
|
||||||
// Shipped orders query - uses date_shipped instead of date_placed
|
// Shipped orders query - uses date_shipped instead of date_placed
|
||||||
// This counts orders that were SHIPPED during the selected period, regardless of when they were placed
|
// This counts orders that were SHIPPED during the selected period, regardless of when they were placed
|
||||||
const shippedQuery = `
|
const shippedQuery = `
|
||||||
@@ -115,9 +135,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
|
AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [shippedResult] = await connection.execute(shippedQuery, params);
|
|
||||||
const shippedCount = parseInt(shippedResult[0]?.shippedCount || 0);
|
|
||||||
|
|
||||||
// Best revenue day query (optionally excludes Cherry Box orders)
|
// Best revenue day query (optionally excludes Cherry Box orders)
|
||||||
const bestDayQuery = `
|
const bestDayQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -131,78 +148,38 @@ router.get('/stats', async (req, res) => {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
|
||||||
|
|
||||||
// Peak hour query - uses selected time range for the card value.
|
// Peak hour query - uses selected time range for the card value.
|
||||||
// date_placed stores Central wall-clock; the office reads the axis in
|
// date_placed stores Central wall-clock; the office reads the axis in
|
||||||
// Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
|
// Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
|
||||||
let peakHour = null;
|
// Only run for single-day ranges (see tasks below).
|
||||||
if (['today', 'yesterday'].includes(timeRange)) {
|
const peakHourQuery = `
|
||||||
const peakHourQuery = `
|
SELECT
|
||||||
SELECT
|
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
COUNT(*) as count
|
||||||
COUNT(*) as count
|
FROM _order
|
||||||
FROM _order
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
||||||
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
ORDER BY count DESC
|
||||||
ORDER BY count DESC
|
LIMIT 1
|
||||||
LIMIT 1
|
`;
|
||||||
`;
|
|
||||||
|
|
||||||
const [peakHourResult] = await connection.execute(peakHourQuery, params);
|
|
||||||
if (peakHourResult.length > 0) {
|
|
||||||
const hour = parseInt(peakHourResult[0].hour);
|
|
||||||
peakHour = {
|
|
||||||
hour,
|
|
||||||
count: parseInt(peakHourResult[0].count),
|
|
||||||
displayHour: DateTime.fromObject({ hour }).toFormat('h a')
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days)
|
// Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days)
|
||||||
// Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour]
|
// Hourly counts in EASTERN display hours (column stores Central
|
||||||
let hourlyOrders = null;
|
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same
|
||||||
if (['today', 'yesterday'].includes(timeRange)) {
|
// expression so the rolling window stays aligned.
|
||||||
// Hourly counts in EASTERN display hours (column stores Central
|
const hourlyQuery = `
|
||||||
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same
|
SELECT
|
||||||
// expression so the rolling window stays aligned.
|
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||||
const hourlyQuery = `
|
COUNT(*) as count,
|
||||||
SELECT
|
HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
|
||||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
FROM _order
|
||||||
COUNT(*) as count,
|
WHERE order_status > 15
|
||||||
HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
|
AND ${getCherryBoxClause(excludeCB)}
|
||||||
FROM _order
|
AND date_placed >= NOW() - INTERVAL 24 HOUR
|
||||||
WHERE order_status > 15
|
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
||||||
AND ${getCherryBoxClause(excludeCB)}
|
`;
|
||||||
AND date_placed >= NOW() - INTERVAL 24 HOUR
|
|
||||||
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
|
||||||
`;
|
|
||||||
|
|
||||||
const [hourlyResult] = await connection.execute(hourlyQuery);
|
|
||||||
|
|
||||||
// Current hour in Eastern (fallback computes it directly)
|
|
||||||
const currentHour = hourlyResult.length > 0
|
|
||||||
? parseInt(hourlyResult[0].currentHour)
|
|
||||||
: DateTime.now().setZone(TIMEZONE).hour;
|
|
||||||
|
|
||||||
// Build map of hour -> count
|
|
||||||
const hourCounts = {};
|
|
||||||
hourlyResult.forEach(row => {
|
|
||||||
hourCounts[parseInt(row.hour)] = parseInt(row.count);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build array in chronological order starting from (currentHour + 1) which is 24 hours ago
|
|
||||||
hourlyOrders = [];
|
|
||||||
for (let i = 0; i < 24; i++) {
|
|
||||||
const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour
|
|
||||||
hourlyOrders.push({
|
|
||||||
hour,
|
|
||||||
count: hourCounts[hour] || 0
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Brands query - products.company links to product_categories.cat_id for brand name
|
// Brands query - products.company links to product_categories.cat_id for brand name
|
||||||
// Only include products that have a brand assigned (INNER JOIN)
|
// Only include products that have a brand assigned (INNER JOIN)
|
||||||
const brandsQuery = `
|
const brandsQuery = `
|
||||||
@@ -223,8 +200,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
LIMIT 100
|
LIMIT 100
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [brandsResult] = await connection.execute(brandsQuery, params);
|
|
||||||
|
|
||||||
// Categories query - uses product_category_index to get category assignments
|
// Categories query - uses product_category_index to get category assignments
|
||||||
// Only include categories with valid types (no NULL/uncategorized)
|
// Only include categories with valid types (no NULL/uncategorized)
|
||||||
const categoriesQuery = `
|
const categoriesQuery = `
|
||||||
@@ -249,8 +224,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
LIMIT 100
|
LIMIT 100
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
|
||||||
|
|
||||||
// Shipping locations query - uses date_shipped to match shippedCount
|
// Shipping locations query - uses date_shipped to match shippedCount
|
||||||
const shippingQuery = `
|
const shippingQuery = `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -265,11 +238,6 @@ router.get('/stats', async (req, res) => {
|
|||||||
GROUP BY ship_country, ship_state, ship_method_selected
|
GROUP BY ship_country, ship_state, ship_method_selected
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [shippingResult] = await connection.execute(shippingQuery, params);
|
|
||||||
|
|
||||||
// Process shipping data
|
|
||||||
const shippingStats = processShippingData(shippingResult, shippedCount);
|
|
||||||
|
|
||||||
// Order value range query (optionally excludes Cherry Box orders)
|
// Order value range query (optionally excludes Cherry Box orders)
|
||||||
// Excludes $0 orders from min calculation
|
// Excludes $0 orders from min calculation
|
||||||
const orderRangeQuery = `
|
const orderRangeQuery = `
|
||||||
@@ -280,17 +248,78 @@ router.get('/stats', async (req, res) => {
|
|||||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [orderRangeResult] = await connection.execute(orderRangeQuery, params);
|
// Run everything in parallel; heavier joins first so they don't tail-end
|
||||||
|
// the worker queue.
|
||||||
|
const rows = (sql, p = params) => (conn) => conn.execute(sql, p).then(([result]) => result);
|
||||||
|
const tasks = [
|
||||||
|
['brands', rows(brandsQuery)],
|
||||||
|
['categories', rows(categoriesQuery)],
|
||||||
|
['mainStats', rows(mainStatsQuery)],
|
||||||
|
['prevPeriod', (conn) => getPreviousPeriodData(conn, timeRange, startDate, endDate, excludeCB)],
|
||||||
|
['cancelled', rows(cancelledQuery)],
|
||||||
|
['refunds', rows(refundsQuery)],
|
||||||
|
['shipped', rows(shippedQuery)],
|
||||||
|
['bestDay', rows(bestDayQuery)],
|
||||||
|
['shipping', rows(shippingQuery)],
|
||||||
|
['orderRange', rows(orderRangeQuery)],
|
||||||
|
];
|
||||||
|
if (isSingleDay) {
|
||||||
|
tasks.push(['peakHour', rows(peakHourQuery)]);
|
||||||
|
tasks.push(['hourly', rows(hourlyQuery, [])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await runStatsTasks(tasks, STATS_QUERY_WIDTH);
|
||||||
|
|
||||||
|
const stats = r.mainStats[0];
|
||||||
|
const cancelledStats = r.cancelled[0] || { cancelledCount: 0, cancelledTotal: 0 };
|
||||||
|
const refundStats = r.refunds;
|
||||||
|
const shippedCount = parseInt(r.shipped[0]?.shippedCount || 0);
|
||||||
|
const bestDayResult = r.bestDay;
|
||||||
|
const brandsResult = r.brands;
|
||||||
|
const categoriesResult = r.categories;
|
||||||
|
const orderRangeResult = r.orderRange;
|
||||||
|
const prevPeriodData = r.prevPeriod;
|
||||||
|
const shippingStats = processShippingData(r.shipping, shippedCount);
|
||||||
|
|
||||||
|
let peakHour = null;
|
||||||
|
if (isSingleDay && r.peakHour.length > 0) {
|
||||||
|
const hour = parseInt(r.peakHour[0].hour);
|
||||||
|
peakHour = {
|
||||||
|
hour,
|
||||||
|
count: parseInt(r.peakHour[0].count),
|
||||||
|
displayHour: DateTime.fromObject({ hour }).toFormat('h a')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns data ordered chronologically: [24hrs ago, ..., 1hr ago, current hour]
|
||||||
|
let hourlyOrders = null;
|
||||||
|
if (isSingleDay) {
|
||||||
|
// Current hour in Eastern (fallback computes it directly)
|
||||||
|
const currentHour = r.hourly.length > 0
|
||||||
|
? parseInt(r.hourly[0].currentHour)
|
||||||
|
: DateTime.now().setZone(TIMEZONE).hour;
|
||||||
|
|
||||||
|
const hourCounts = {};
|
||||||
|
r.hourly.forEach(row => {
|
||||||
|
hourCounts[parseInt(row.hour)] = parseInt(row.count);
|
||||||
|
});
|
||||||
|
|
||||||
|
hourlyOrders = [];
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour
|
||||||
|
hourlyOrders.push({
|
||||||
|
hour,
|
||||||
|
count: hourCounts[hour] || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate period progress for incomplete periods
|
// Calculate period progress for incomplete periods
|
||||||
let periodProgress = 100;
|
let periodProgress = 100;
|
||||||
if (['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
if (['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
||||||
periodProgress = calculatePeriodProgress(timeRange);
|
periodProgress = calculatePeriodProgress(timeRange);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Previous period comparison data
|
|
||||||
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
|
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
timeRange: dateRange,
|
timeRange: dateRange,
|
||||||
stats: {
|
stats: {
|
||||||
@@ -384,27 +413,57 @@ router.get('/stats', async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return response;
|
return response;
|
||||||
} finally {
|
}
|
||||||
// Always release the connection regardless of whether the outer Promise.race
|
|
||||||
// used our result. If the timeout wins, this IIFE keeps running in the
|
|
||||||
// background until MySQL responds, then this finally releases. Without it,
|
|
||||||
// every timed-out request permanently leaks one pool slot.
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const response = await Promise.race([mainOperation(), timeoutPromise]);
|
// Main stats endpoint - replaces /api/klaviyo/events/stats
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||||
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
|
const cacheKey = `${timeRange || ''}|${startDate || ''}|${endDate || ''}|${excludeCB}`;
|
||||||
|
|
||||||
console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`);
|
// Single-flight refresh: concurrent misses for the same key share one
|
||||||
res.json(response);
|
// computation instead of racing multi-connection batches against the pool.
|
||||||
|
const refresh = () => {
|
||||||
|
let inflight = statsInflight.get(cacheKey);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
inflight = withTimeout(computeStats(timeRange, startDate, endDate, excludeCB), 15000, '[STATS]')
|
||||||
|
.then((data) => {
|
||||||
|
statsCache.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
// Arbitrary custom date ranges would otherwise grow the map forever
|
||||||
|
if (statsCache.size > 100) {
|
||||||
|
const oldest = [...statsCache.entries()]
|
||||||
|
.reduce((a, b) => (a[1].timestamp <= b[1].timestamp ? a : b));
|
||||||
|
statsCache.delete(oldest[0]);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.finally(() => statsInflight.delete(cacheKey));
|
||||||
|
statsInflight.set(cacheKey, inflight);
|
||||||
|
return inflight;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = statsCache.get(cacheKey);
|
||||||
|
const age = cached ? Date.now() - cached.timestamp : Infinity;
|
||||||
|
|
||||||
|
if (cached && age >= STATS_CACHE_TTL && age < STATS_CACHE_MAX_STALE) {
|
||||||
|
refresh().catch((err) => console.error('[STATS] Background refresh failed:', err.message));
|
||||||
|
}
|
||||||
|
if (cached && age < STATS_CACHE_MAX_STALE) {
|
||||||
|
console.log(`[STATS] Cache hit (${Math.round(age / 1000)}s old) for ${cacheKey}`);
|
||||||
|
return res.json(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await refresh();
|
||||||
|
console.log(`[STATS] Computed ${cacheKey} in ${Date.now() - startTime}ms`);
|
||||||
|
res.json(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.message.includes('timeout')) {
|
if (error.message.includes('timeout')) {
|
||||||
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
|
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
|
||||||
} else {
|
} else {
|
||||||
console.error('Error in /stats:', error);
|
console.error('Error in /stats:', error);
|
||||||
}
|
}
|
||||||
console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`);
|
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -812,23 +871,15 @@ router.get('/products', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Projection endpoint - replaces /api/klaviyo/events/projection
|
// Same stale-while-revalidate treatment as /stats: several tunnel round trips
|
||||||
router.get('/projection', async (req, res) => {
|
// per computation (~800ms), polled every 60s by every dashboard.
|
||||||
|
const projectionCache = new Map(); // cacheKey -> { data, timestamp }
|
||||||
|
const projectionInflight = new Map(); // cacheKey -> Promise<data>
|
||||||
|
|
||||||
|
async function computeProjection(timeRange, startDate, endDate, excludeCB) {
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
let release;
|
const { connection, release } = await getDbConnection();
|
||||||
try {
|
try {
|
||||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
|
||||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
|
||||||
console.log(`[PROJECTION] Starting request for timeRange: ${timeRange}`);
|
|
||||||
|
|
||||||
// Only provide projections for incomplete periods
|
|
||||||
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
|
||||||
return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { connection, release: releaseConn } = await getDbConnection();
|
|
||||||
release = releaseConn;
|
|
||||||
console.log(`[PROJECTION] DB connection obtained in ${Date.now() - startTime}ms`);
|
|
||||||
|
|
||||||
const now = DateTime.now().setZone(TIMEZONE);
|
const now = DateTime.now().setZone(TIMEZONE);
|
||||||
|
|
||||||
@@ -877,15 +928,52 @@ router.get('/projection', async (req, res) => {
|
|||||||
projection.currentRevenue = parseFloat(current.currentRevenue || 0);
|
projection.currentRevenue = parseFloat(current.currentRevenue || 0);
|
||||||
projection.currentOrders = parseInt(current.currentOrders || 0);
|
projection.currentOrders = parseInt(current.currentOrders || 0);
|
||||||
|
|
||||||
console.log(`[PROJECTION] Request completed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
|
console.log(`[PROJECTION] Computed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
|
||||||
res.json(projection);
|
return projection;
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error);
|
|
||||||
res.status(500).json({ error: error.message });
|
|
||||||
} finally {
|
} finally {
|
||||||
// Release connection back to pool
|
release();
|
||||||
if (release) release();
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Projection endpoint - replaces /api/klaviyo/events/projection
|
||||||
|
router.get('/projection', async (req, res) => {
|
||||||
|
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||||
|
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||||
|
|
||||||
|
// Only provide projections for incomplete periods
|
||||||
|
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
||||||
|
return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${timeRange}|${excludeCB}`;
|
||||||
|
const refresh = () => {
|
||||||
|
let inflight = projectionInflight.get(cacheKey);
|
||||||
|
if (inflight) return inflight;
|
||||||
|
inflight = computeProjection(timeRange, startDate, endDate, excludeCB)
|
||||||
|
.then((data) => {
|
||||||
|
projectionCache.set(cacheKey, { data, timestamp: Date.now() });
|
||||||
|
return data;
|
||||||
|
})
|
||||||
|
.finally(() => projectionInflight.delete(cacheKey));
|
||||||
|
projectionInflight.set(cacheKey, inflight);
|
||||||
|
return inflight;
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = projectionCache.get(cacheKey);
|
||||||
|
const age = cached ? Date.now() - cached.timestamp : Infinity;
|
||||||
|
|
||||||
|
if (cached && age >= STATS_CACHE_TTL && age < STATS_CACHE_MAX_STALE) {
|
||||||
|
refresh().catch((err) => console.error('[PROJECTION] Background refresh failed:', err.message));
|
||||||
|
}
|
||||||
|
if (cached && age < STATS_CACHE_MAX_STALE) {
|
||||||
|
return res.json(cached.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(await refresh());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PROJECTION] Error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -314,7 +314,6 @@ async function syncSettingsProductTable() {
|
|||||||
lead_time_days,
|
lead_time_days,
|
||||||
days_of_stock,
|
days_of_stock,
|
||||||
safety_stock,
|
safety_stock,
|
||||||
forecast_method,
|
|
||||||
exclude_from_forecast
|
exclude_from_forecast
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
@@ -322,7 +321,6 @@ async function syncSettingsProductTable() {
|
|||||||
CAST(NULL AS INTEGER),
|
CAST(NULL AS INTEGER),
|
||||||
CAST(NULL AS INTEGER),
|
CAST(NULL AS INTEGER),
|
||||||
COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0),
|
COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0),
|
||||||
CAST(NULL AS VARCHAR),
|
|
||||||
FALSE
|
FALSE
|
||||||
FROM
|
FROM
|
||||||
public.products p
|
public.products p
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ router.put('/products/:pid', async (req, res) => {
|
|||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
try {
|
try {
|
||||||
const { pid } = req.params;
|
const { pid } = req.params;
|
||||||
const { lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast } = req.body;
|
const { lead_time_days, days_of_stock, safety_stock, exclude_from_forecast } = req.body;
|
||||||
|
|
||||||
console.log(`[Config Route] Updating product settings for ${pid}:`, req.body);
|
console.log(`[Config Route] Updating product settings for ${pid}:`, req.body);
|
||||||
|
|
||||||
@@ -149,10 +149,10 @@ router.put('/products/:pid', async (req, res) => {
|
|||||||
if (checkProduct.length === 0) {
|
if (checkProduct.length === 0) {
|
||||||
// Insert if it doesn't exist
|
// Insert if it doesn't exist
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`INSERT INTO settings_product
|
`INSERT INTO settings_product
|
||||||
(pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast)
|
(pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
VALUES ($1, $2, $3, $4, $5)`,
|
||||||
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast]
|
[pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast]
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Update if it exists
|
// Update if it exists
|
||||||
@@ -161,11 +161,10 @@ router.put('/products/:pid', async (req, res) => {
|
|||||||
SET lead_time_days = $2,
|
SET lead_time_days = $2,
|
||||||
days_of_stock = $3,
|
days_of_stock = $3,
|
||||||
safety_stock = $4,
|
safety_stock = $4,
|
||||||
forecast_method = $5,
|
exclude_from_forecast = $5,
|
||||||
exclude_from_forecast = $6,
|
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE pid::text = $1`,
|
WHERE pid::text = $1`,
|
||||||
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast]
|
[pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,7 +189,6 @@ router.post('/products/:pid/reset', async (req, res) => {
|
|||||||
SET lead_time_days = NULL,
|
SET lead_time_days = NULL,
|
||||||
days_of_stock = NULL,
|
days_of_stock = NULL,
|
||||||
safety_stock = 0,
|
safety_stock = 0,
|
||||||
forecast_method = NULL,
|
|
||||||
exclude_from_forecast = false,
|
exclude_from_forecast = false,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE pid::text = $1`,
|
WHERE pid::text = $1`,
|
||||||
|
|||||||
@@ -1931,6 +1931,76 @@ router.post('/generate-upc', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bulk "does this UPC already exist" check for the import validator.
|
||||||
|
// The one-at-a-time alternative is /search-products?q=<upc>, but that runs a
|
||||||
|
// five-column LIKE '%q%' scan across ten joined tables (~6s each) and every call
|
||||||
|
// queues on the single shared connection from getDbConnection() — a 20-product
|
||||||
|
// batch takes minutes. This answers the whole batch with one indexed IN() lookup
|
||||||
|
// on idx_upc. Matches /check-upc-and-generate-sku's semantics: `upc` only, not upc_2.
|
||||||
|
const MAX_UPCS_PER_CHECK = 500;
|
||||||
|
|
||||||
|
router.get('/check-upcs', async (req, res) => {
|
||||||
|
const { upcs } = req.query;
|
||||||
|
|
||||||
|
if (!upcs) {
|
||||||
|
return res.status(400).json({ error: 'upcs query parameter is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe and keep only well-formed barcodes — anything else can't match the
|
||||||
|
// column anyway, and dropping it keeps the IN() list tight.
|
||||||
|
const upcList = [...new Set(
|
||||||
|
String(upcs).split(',').map(s => s.trim()).filter(s => /^\d{8,14}$/.test(s))
|
||||||
|
)];
|
||||||
|
|
||||||
|
if (upcList.length === 0) {
|
||||||
|
return res.json({ found: {}, checked: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (upcList.length > MAX_UPCS_PER_CHECK) {
|
||||||
|
return res.status(400).json({
|
||||||
|
error: `Too many UPCs (${upcList.length}); send at most ${MAX_UPCS_PER_CHECK} per request`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connection } = await getDbConnection();
|
||||||
|
|
||||||
|
const placeholders = upcList.map(() => '?').join(',');
|
||||||
|
const [rows] = await connection.query(
|
||||||
|
`SELECT pid, upc, itemnumber, description FROM products WHERE upc IN (${placeholders})`,
|
||||||
|
upcList
|
||||||
|
);
|
||||||
|
|
||||||
|
// idx_upc is non-unique, so a UPC can legitimately hit more than one product.
|
||||||
|
// Report the lowest pid (the original) and say how many share it.
|
||||||
|
const found = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = String(row.upc);
|
||||||
|
const existing = found[key];
|
||||||
|
if (!existing) {
|
||||||
|
found[key] = {
|
||||||
|
pid: row.pid,
|
||||||
|
itemNumber: row.itemnumber,
|
||||||
|
title: row.description,
|
||||||
|
matchCount: 1
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
existing.matchCount += 1;
|
||||||
|
if (row.pid < existing.pid) {
|
||||||
|
existing.pid = row.pid;
|
||||||
|
existing.itemNumber = row.itemnumber;
|
||||||
|
existing.title = row.description;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ found, checked: upcList.length });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking UPCs:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to check UPCs', details: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Endpoint to check UPC and generate item number
|
// Endpoint to check UPC and generate item number
|
||||||
router.get('/check-upc-and-generate-sku', async (req, res) => {
|
router.get('/check-upc-and-generate-sku', async (req, res) => {
|
||||||
const { upc, supplierId } = req.query;
|
const { upc, supplierId } = req.query;
|
||||||
|
|||||||
Generated
-35
@@ -31,7 +31,6 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
@@ -2434,40 +2433,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@radix-ui/react-toast": {
|
|
||||||
"version": "1.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.6.tgz",
|
|
||||||
"integrity": "sha512-gN4dpuIVKEgpLn1z5FhzT9mYRUitbfZq9XqN/7kkBMUgFTzTG8x/KszWJugJXHcwxckY8xcKDZPz7kG3o6DsUA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@radix-ui/primitive": "1.1.1",
|
|
||||||
"@radix-ui/react-collection": "1.1.2",
|
|
||||||
"@radix-ui/react-compose-refs": "1.1.1",
|
|
||||||
"@radix-ui/react-context": "1.1.1",
|
|
||||||
"@radix-ui/react-dismissable-layer": "1.1.5",
|
|
||||||
"@radix-ui/react-portal": "1.1.4",
|
|
||||||
"@radix-ui/react-presence": "1.1.2",
|
|
||||||
"@radix-ui/react-primitive": "2.0.2",
|
|
||||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
|
||||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
|
||||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
|
||||||
"@radix-ui/react-visually-hidden": "1.1.2"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@types/react": "*",
|
|
||||||
"@types/react-dom": "*",
|
|
||||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
|
||||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@types/react": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@types/react-dom": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@radix-ui/react-toggle": {
|
"node_modules/@radix-ui/react-toggle": {
|
||||||
"version": "1.1.10",
|
"version": "1.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz",
|
||||||
|
|||||||
@@ -35,7 +35,6 @@
|
|||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@radix-ui/react-tabs": "^1.1.2",
|
"@radix-ui/react-tabs": "^1.1.2",
|
||||||
"@radix-ui/react-toast": "^1.2.6",
|
|
||||||
"@radix-ui/react-toggle": "^1.1.10",
|
"@radix-ui/react-toggle": "^1.1.10",
|
||||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||||
"@radix-ui/react-tooltip": "^1.1.6",
|
"@radix-ui/react-tooltip": "^1.1.6",
|
||||||
|
|||||||
+15
-10
@@ -36,6 +36,7 @@ const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
|
|||||||
// 2. Dashboard app - separate chunk
|
// 2. Dashboard app - separate chunk
|
||||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||||
const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
|
const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
|
||||||
|
const MobileDashboard = lazy(() => import('./pages/MobileDashboard'));
|
||||||
|
|
||||||
// 3. Product import - separate chunk
|
// 3. Product import - separate chunk
|
||||||
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
|
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
|
||||||
@@ -69,27 +70,26 @@ function App() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
// Token genuinely rejected — clear it and go to login
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
sessionStorage.removeItem('isLoggedIn');
|
sessionStorage.removeItem('isLoggedIn');
|
||||||
|
|
||||||
// Only navigate to login if we're not already there
|
// Only navigate to login if we're not already there
|
||||||
if (!location.pathname.includes('/login')) {
|
if (!location.pathname.includes('/login')) {
|
||||||
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else if (response.ok) {
|
||||||
// If token is valid, set the login flag
|
// If token is valid, set the login flag
|
||||||
sessionStorage.setItem('isLoggedIn', 'true');
|
sessionStorage.setItem('isLoggedIn', 'true');
|
||||||
}
|
}
|
||||||
|
// Other statuses (500/429/etc.) are transient server issues —
|
||||||
|
// keep the token; AuthContext will retry on the next check.
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
// Network error (server down, offline) — keep the token and let a
|
||||||
|
// later check decide. Logging out here would end valid sessions on
|
||||||
|
// every connection blip.
|
||||||
console.error('Token verification failed:', error);
|
console.error('Token verification failed:', error);
|
||||||
localStorage.removeItem('token');
|
|
||||||
sessionStorage.removeItem('isLoggedIn');
|
|
||||||
|
|
||||||
// Only navigate to login if we're not already there
|
|
||||||
if (!location.pathname.includes('/login')) {
|
|
||||||
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -109,6 +109,11 @@ function App() {
|
|||||||
<SmallDashboard />
|
<SmallDashboard />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/mobile" element={
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<MobileDashboard />
|
||||||
|
</Suspense>
|
||||||
|
} />
|
||||||
<Route element={
|
<Route element={
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<MainLayout />
|
<MainLayout />
|
||||||
|
|||||||
@@ -163,17 +163,17 @@ export function AiDescriptionCompare({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right: AI suggestion */}
|
{/* Right: AI suggestion */}
|
||||||
<div className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2">
|
<div className="bg-purple-50/80 flex flex-col w-full lg:w-1/2">
|
||||||
{/* Measured header + issues area (height mirrored as spacer on the left) */}
|
{/* Measured header + issues area (height mirrored as spacer on the left) */}
|
||||||
<div ref={aiHeaderRef} className="flex-shrink-0">
|
<div ref={aiHeaderRef} className="flex-shrink-0">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="w-full flex items-center justify-between px-3 py-2">
|
<div className="w-full flex items-center justify-between px-3 py-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
<span className="text-xs font-medium text-purple-600">
|
||||||
AI Suggestion
|
AI Suggestion
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
<span className="text-xs text-purple-500">
|
||||||
({issues.length} {issues.length === 1 ? "issue" : "issues"})
|
({issues.length} {issues.length === 1 ? "issue" : "issues"})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +185,7 @@ export function AiDescriptionCompare({
|
|||||||
{issues.map((issue, index) => (
|
{issues.map((issue, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400"
|
className="flex items-start gap-1.5 text-xs text-purple-600"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
<span>{issue}</span>
|
<span>{issue}</span>
|
||||||
@@ -199,7 +199,7 @@ export function AiDescriptionCompare({
|
|||||||
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
||||||
{/* Editable suggestion */}
|
{/* Editable suggestion */}
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0">
|
<div className="text-sm text-purple-500 mb-1 font-medium flex-shrink-0">
|
||||||
Suggested (editable):
|
Suggested (editable):
|
||||||
</div>
|
</div>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -209,7 +209,7 @@ export function AiDescriptionCompare({
|
|||||||
setEditedSuggestion(e.target.value);
|
setEditedSuggestion(e.target.value);
|
||||||
syncTextareaHeights();
|
syncTextareaHeights();
|
||||||
}}
|
}}
|
||||||
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y min-h-[120px] max-h-[50vh]"
|
className="overflow-y-auto overscroll-contain text-sm bg-white border-purple-200 focus-visible:ring-purple-400 resize-y min-h-[120px] max-h-[50vh]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ export function AiDescriptionCompare({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
|
||||||
onClick={() => onAccept(editedSuggestion)}
|
onClick={() => onAccept(editedSuggestion)}
|
||||||
>
|
>
|
||||||
<Check className="h-3 w-3 mr-1" />
|
<Check className="h-3 w-3 mr-1" />
|
||||||
@@ -227,7 +227,7 @@ export function AiDescriptionCompare({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
className="h-7 px-3 text-xs text-muted-foreground hover:text-foreground"
|
||||||
onClick={onDismiss}
|
onClick={onDismiss}
|
||||||
>
|
>
|
||||||
Ignore
|
Ignore
|
||||||
@@ -236,7 +236,7 @@ export function AiDescriptionCompare({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-3 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400"
|
className="h-7 px-3 text-xs text-purple-500 hover:text-purple-700"
|
||||||
disabled={isRevalidating}
|
disabled={isRevalidating}
|
||||||
onClick={onRevalidate}
|
onClick={onRevalidate}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ export function BulkEditRow({
|
|||||||
{state.result!.issues.map((issue, i) => (
|
{state.result!.issues.map((issue, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400"
|
className="flex items-start gap-1 text-[11px] text-purple-600"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
<span>{issue}</span>
|
<span>{issue}</span>
|
||||||
@@ -223,12 +223,12 @@ export function BulkEditRow({
|
|||||||
<Input
|
<Input
|
||||||
value={state.editedSuggestion ?? state.result!.suggestion!}
|
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||||
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
||||||
className="h-7 text-sm flex-1 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-purple-50/50 dark:bg-purple-950/20"
|
className="h-7 text-sm flex-1 border-purple-200 focus-visible:ring-purple-400 bg-purple-50/50"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 px-2 text-xs shrink-0 bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
className="h-7 px-2 text-xs shrink-0 bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||||
}
|
}
|
||||||
@@ -239,7 +239,7 @@ export function BulkEditRow({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-1.5 text-xs text-gray-500 hover:text-gray-700 shrink-0"
|
className="h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0"
|
||||||
onClick={() => onDismiss(product.pid)}
|
onClick={() => onDismiss(product.pid)}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
@@ -254,11 +254,11 @@ export function BulkEditRow({
|
|||||||
if (!showSuggestion) return null;
|
if (!showSuggestion) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-1.5 min-w-0 flex-1 bg-purple-50/60 dark:bg-purple-950/20 rounded-md p-2">
|
<div className="flex flex-col gap-1.5 min-w-0 flex-1 bg-purple-50/60 rounded-md p-2">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
<span className="text-xs font-medium text-purple-600">
|
||||||
AI Suggestion
|
AI Suggestion
|
||||||
</span>
|
</span>
|
||||||
{state.result!.issues.length > 0 && (
|
{state.result!.issues.length > 0 && (
|
||||||
@@ -274,7 +274,7 @@ export function BulkEditRow({
|
|||||||
{state.result!.issues.map((issue, i) => (
|
{state.result!.issues.map((issue, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400"
|
className="flex items-start gap-1 text-[11px] text-purple-600"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
<span>{issue}</span>
|
<span>{issue}</span>
|
||||||
@@ -287,7 +287,7 @@ export function BulkEditRow({
|
|||||||
<Textarea
|
<Textarea
|
||||||
value={state.editedSuggestion ?? state.result!.suggestion!}
|
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||||
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
|
||||||
className="text-sm min-h-[60px] max-h-[120px] resize-y border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-white dark:bg-black/20"
|
className="text-sm min-h-[60px] max-h-[120px] resize-y border-purple-200 focus-visible:ring-purple-400 bg-white"
|
||||||
rows={2}
|
rows={2}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -296,7 +296,7 @@ export function BulkEditRow({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 px-2 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
className="h-7 px-2 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||||
}
|
}
|
||||||
@@ -307,7 +307,7 @@ export function BulkEditRow({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-2 text-xs text-gray-500 hover:text-gray-700"
|
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => onDismiss(product.pid)}
|
onClick={() => onDismiss(product.pid)}
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
|||||||
case 'd':
|
case 'd':
|
||||||
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
||||||
default:
|
default:
|
||||||
return <Hash className="h-4 w-4 text-gray-500" />;
|
return <Hash className="h-4 w-4 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="mt-2 space-y-2">
|
<div className="mt-2 space-y-2">
|
||||||
{urls.map((urlData, index) => (
|
{urls.map((urlData, index) => (
|
||||||
<div key={index} className="border rounded-lg p-3 bg-gray-50">
|
<div key={index} className="border rounded-lg p-3 bg-surface-subtle">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<ExternalLink className="h-4 w-4 mt-1 text-blue-500 flex-shrink-0" />
|
<ExternalLink className="h-4 w-4 mt-1 text-blue-500 flex-shrink-0" />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -335,7 +335,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={index} className="border rounded-lg p-3 bg-gray-50">
|
<div key={index} className="border rounded-lg p-3 bg-surface-subtle">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<Image className="h-4 w-4 text-green-500" />
|
<Image className="h-4 w-4 text-green-500" />
|
||||||
@@ -508,6 +508,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
|||||||
<div className="flex gap-2 mt-2">
|
<div className="flex gap-2 mt-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search messages..."
|
placeholder="Search messages..."
|
||||||
|
className="rounded-full px-3.5"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && searchMessages()}
|
onKeyPress={(e) => e.key === 'Enter' && searchMessages()}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function ChatTest({ selectedUserId }: ChatTestProps) {
|
|||||||
case 'd':
|
case 'd':
|
||||||
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
||||||
default:
|
default:
|
||||||
return <Users className="h-4 w-4 text-gray-500" />;
|
return <Users className="h-4 w-4 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ export function ChatTest({ selectedUserId }: ChatTestProps) {
|
|||||||
{rooms.map((room) => (
|
{rooms.map((room) => (
|
||||||
<div
|
<div
|
||||||
key={room.id}
|
key={room.id}
|
||||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50"
|
className="flex items-center justify-between p-3 border rounded-lg hover:bg-surface-subtle"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{getRoomIcon(room.type)}
|
{getRoomIcon(room.type)}
|
||||||
|
|||||||
@@ -193,8 +193,8 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
|||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<div className="h-12 w-12 bg-gray-50 rounded-full flex items-center justify-center">
|
<div className="h-12 w-12 bg-surface-subtle rounded-full flex items-center justify-center">
|
||||||
<Users className="h-6 w-6 text-gray-500" />
|
<Users className="h-6 w-6 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={title} className="space-y-0.5">
|
<div key={title} className="space-y-0.5">
|
||||||
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100">
|
<div className="flex items-center gap-2 px-2 py-1 border-b border-muted">
|
||||||
{icon}
|
{icon}
|
||||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||||
{title} ({rooms.length})
|
{title} ({rooms.length})
|
||||||
@@ -281,7 +281,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
|||||||
onClick={() => onRoomSelect(room.id.toString())}
|
onClick={() => onRoomSelect(room.id.toString())}
|
||||||
className={`
|
className={`
|
||||||
flex items-center justify-between py-0.5 px-3 rounded-lg cursor-pointer transition-colors
|
flex items-center justify-between py-0.5 px-3 rounded-lg cursor-pointer transition-colors
|
||||||
hover:bg-gray-100
|
hover:bg-muted
|
||||||
${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''}
|
${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''}
|
||||||
${(room.open === false || room.archived === true) ? 'opacity-60' : ''}
|
${(room.open === false || room.archived === true) ? 'opacity-60' : ''}
|
||||||
`}
|
`}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function SearchResults({ results, query, onClose, onRoomSelect }: SearchR
|
|||||||
case 'd':
|
case 'd':
|
||||||
return <MessageSquare className="h-3 w-3 text-green-500" />;
|
return <MessageSquare className="h-3 w-3 text-green-500" />;
|
||||||
default:
|
default:
|
||||||
return <Hash className="h-3 w-3 text-gray-500" />;
|
return <Hash className="h-3 w-3 text-muted-foreground" />;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ export function SearchResults({ results, query, onClose, onRoomSelect }: SearchR
|
|||||||
{results.map((result) => (
|
{results.map((result) => (
|
||||||
<div
|
<div
|
||||||
key={result.id}
|
key={result.id}
|
||||||
className="border rounded-lg p-3 hover:bg-gray-50 cursor-pointer"
|
className="border rounded-lg p-3 hover:bg-surface-subtle cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onRoomSelect(result.room_id.toString());
|
onRoomSelect(result.room_id.toString());
|
||||||
onClose();
|
onClose();
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { TrendingUp } from "lucide-react";
|
import { TrendingUp } from "lucide-react";
|
||||||
@@ -31,12 +30,15 @@ import {
|
|||||||
DashboardChartTooltip,
|
DashboardChartTooltip,
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
|
MetricPill,
|
||||||
|
LegendChip,
|
||||||
|
PILL_TRIGGER_CLASS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
// Note: Using ChartSkeleton from @/components/dashboard/shared
|
// Note: Using ChartSkeleton from @/components/dashboard/shared
|
||||||
|
|
||||||
const SkeletonStats = () => (
|
const SkeletonStats = () => (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
|
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
|
||||||
))}
|
))}
|
||||||
@@ -48,19 +50,19 @@ const SkeletonStats = () => (
|
|||||||
// Add color constants
|
// Add color constants
|
||||||
const METRIC_COLORS = {
|
const METRIC_COLORS = {
|
||||||
activeUsers: {
|
activeUsers: {
|
||||||
color: "#8b5cf6",
|
color: "#6b5fc7",
|
||||||
className: "text-purple-600 dark:text-purple-400",
|
className: "text-purple-600 dark:text-purple-400",
|
||||||
},
|
},
|
||||||
newUsers: {
|
newUsers: {
|
||||||
color: "#10b981",
|
color: "#0f8f77",
|
||||||
className: "text-emerald-600 dark:text-emerald-400",
|
className: "text-emerald-600 dark:text-emerald-400",
|
||||||
},
|
},
|
||||||
pageViews: {
|
pageViews: {
|
||||||
color: "#f59e0b",
|
color: "#a87a24",
|
||||||
className: "text-amber-600 dark:text-amber-400",
|
className: "text-amber-600 dark:text-amber-400",
|
||||||
},
|
},
|
||||||
conversions: {
|
conversions: {
|
||||||
color: "#3b82f6",
|
color: "#e06a4e",
|
||||||
className: "text-blue-600 dark:text-blue-400",
|
className: "text-blue-600 dark:text-blue-400",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -163,7 +165,7 @@ export const AnalyticsDashboard = () => {
|
|||||||
// Time selector for DashboardSectionHeader
|
// Time selector for DashboardSectionHeader
|
||||||
const timeSelector = (
|
const timeSelector = (
|
||||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
<SelectTrigger className="w-[130px] h-9">
|
<SelectTrigger className={PILL_TRIGGER_CLASS}>
|
||||||
<SelectValue placeholder="Select range" />
|
<SelectValue placeholder="Select range" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -179,7 +181,7 @@ export const AnalyticsDashboard = () => {
|
|||||||
const headerActions = !loading ? (
|
const headerActions = !loading ? (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="h-9">
|
<Button variant="outline" size="sm" className="h-8 rounded-full border-[#e7e5e1] text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]">
|
||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -191,8 +193,9 @@ export const AnalyticsDashboard = () => {
|
|||||||
{Object.entries(metrics).map(([key, value]) => (
|
{Object.entries(metrics).map(([key, value]) => (
|
||||||
<Button
|
<Button
|
||||||
key={key}
|
key={key}
|
||||||
variant={value ? "default" : "outline"}
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className={`h-7 rounded-full px-3 text-xs font-medium ${value ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setMetrics((prev) => ({
|
setMetrics((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -270,20 +273,21 @@ export const AnalyticsDashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`w-full ${CARD_STYLES.base}`}>
|
<Card className={`w-full h-full ${CARD_STYLES.base}`}>
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="Analytics Overview"
|
title="Analytics Overview"
|
||||||
|
size="large"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
timeSelector={timeSelector}
|
timeSelector={timeSelector}
|
||||||
actions={headerActions}
|
actions={headerActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0 space-y-4">
|
<CardContent className="p-4 pt-3 space-y-3">
|
||||||
{/* Stats cards */}
|
{/* Stats cards */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<SkeletonStats />
|
<SkeletonStats />
|
||||||
) : summaryStats ? (
|
) : summaryStats ? (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 dashboard-stagger">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 dashboard-stagger">
|
||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
title="Active Users"
|
title="Active Users"
|
||||||
value={summaryStats.totals.activeUsers.toLocaleString()}
|
value={summaryStats.totals.activeUsers.toLocaleString()}
|
||||||
@@ -319,66 +323,36 @@ export const AnalyticsDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* Metric toggles */}
|
{/* Metric toggle pills — the pill dot doubles as the series legend */}
|
||||||
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
<div className="flex flex-wrap gap-1">
|
<MetricPill
|
||||||
<Button
|
active={metrics.activeUsers}
|
||||||
variant={metrics.activeUsers ? "default" : "outline"}
|
color={METRIC_COLORS.activeUsers.color}
|
||||||
size="sm"
|
onClick={() => setMetrics((prev) => ({ ...prev, activeUsers: !prev.activeUsers }))}
|
||||||
className="font-medium"
|
>
|
||||||
onClick={() =>
|
Active users
|
||||||
setMetrics((prev) => ({
|
</MetricPill>
|
||||||
...prev,
|
<MetricPill
|
||||||
activeUsers: !prev.activeUsers,
|
active={metrics.newUsers}
|
||||||
}))
|
color={METRIC_COLORS.newUsers.color}
|
||||||
}
|
onClick={() => setMetrics((prev) => ({ ...prev, newUsers: !prev.newUsers }))}
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">Active Users</span>
|
New users
|
||||||
<span className="sm:hidden">Active</span>
|
</MetricPill>
|
||||||
</Button>
|
<MetricPill
|
||||||
<Button
|
active={metrics.pageViews}
|
||||||
variant={metrics.newUsers ? "default" : "outline"}
|
color={METRIC_COLORS.pageViews.color}
|
||||||
size="sm"
|
onClick={() => setMetrics((prev) => ({ ...prev, pageViews: !prev.pageViews }))}
|
||||||
className="font-medium"
|
>
|
||||||
onClick={() =>
|
Page views
|
||||||
setMetrics((prev) => ({
|
</MetricPill>
|
||||||
...prev,
|
<MetricPill
|
||||||
newUsers: !prev.newUsers,
|
active={metrics.conversions}
|
||||||
}))
|
color={METRIC_COLORS.conversions.color}
|
||||||
}
|
onClick={() => setMetrics((prev) => ({ ...prev, conversions: !prev.conversions }))}
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">New Users</span>
|
Conversions
|
||||||
<span className="sm:hidden">New</span>
|
</MetricPill>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={metrics.pageViews ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="font-medium"
|
|
||||||
onClick={() =>
|
|
||||||
setMetrics((prev) => ({
|
|
||||||
...prev,
|
|
||||||
pageViews: !prev.pageViews,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">Page Views</span>
|
|
||||||
<span className="sm:hidden">Views</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={metrics.conversions ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
className="font-medium"
|
|
||||||
onClick={() =>
|
|
||||||
setMetrics((prev) => ({
|
|
||||||
...prev,
|
|
||||||
conversions: !prev.conversions,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">Conversions</span>
|
|
||||||
<span className="sm:hidden">Conv.</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ChartSkeleton height="default" withCard={false} />
|
<ChartSkeleton height="default" withCard={false} />
|
||||||
@@ -389,11 +363,11 @@ export const AnalyticsDashboard = () => {
|
|||||||
description="Try selecting a different time range"
|
description="Try selecting a different time range"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
<div className="h-[340px] p-0 relative">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart
|
<LineChart
|
||||||
data={data}
|
data={data}
|
||||||
margin={{ top: 5, right: -30, left: -5, bottom: 5 }}
|
margin={{ top: 8, right: 0, left: -8, bottom: 0 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
@@ -424,7 +398,6 @@ export const AnalyticsDashboard = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Legend />
|
|
||||||
{metrics.activeUsers && (
|
{metrics.activeUsers && (
|
||||||
<Line
|
<Line
|
||||||
yAxisId="left"
|
yAxisId="left"
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -73,36 +73,12 @@ const EVENT_ICONS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EVENT_TYPES = {
|
const EVENT_TYPES = {
|
||||||
[METRIC_IDS.PLACED_ORDER]: {
|
[METRIC_IDS.PLACED_ORDER]: { label: "Order Placed" },
|
||||||
label: "Order Placed",
|
[METRIC_IDS.SHIPPED_ORDER]: { label: "Order Shipped" },
|
||||||
color: "bg-green-500 dark:bg-green-600",
|
[METRIC_IDS.ACCOUNT_CREATED]: { label: "New Account" },
|
||||||
textColor: "text-green-600 dark:text-green-400",
|
[METRIC_IDS.CANCELED_ORDER]: { label: "Order Canceled" },
|
||||||
},
|
[METRIC_IDS.PAYMENT_REFUNDED]: { label: "Payment Refunded" },
|
||||||
[METRIC_IDS.SHIPPED_ORDER]: {
|
[METRIC_IDS.NEW_BLOG_POST]: { label: "New Blog Post" },
|
||||||
label: "Order Shipped",
|
|
||||||
color: "bg-blue-500 dark:bg-blue-600",
|
|
||||||
textColor: "text-blue-600 dark:text-blue-400",
|
|
||||||
},
|
|
||||||
[METRIC_IDS.ACCOUNT_CREATED]: {
|
|
||||||
label: "New Account",
|
|
||||||
color: "bg-purple-500 dark:bg-purple-600",
|
|
||||||
textColor: "text-purple-600 dark:text-purple-400",
|
|
||||||
},
|
|
||||||
[METRIC_IDS.CANCELED_ORDER]: {
|
|
||||||
label: "Order Canceled",
|
|
||||||
color: "bg-red-500 dark:bg-red-600",
|
|
||||||
textColor: "text-red-600 dark:text-red-400",
|
|
||||||
},
|
|
||||||
[METRIC_IDS.PAYMENT_REFUNDED]: {
|
|
||||||
label: "Payment Refunded",
|
|
||||||
color: "bg-orange-500 dark:bg-orange-600",
|
|
||||||
textColor: "text-orange-600 dark:text-orange-400",
|
|
||||||
},
|
|
||||||
[METRIC_IDS.NEW_BLOG_POST]: {
|
|
||||||
label: "New Blog Post",
|
|
||||||
color: "bg-indigo-500 dark:bg-indigo-600",
|
|
||||||
textColor: "text-indigo-600 dark:text-indigo-400",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
@@ -146,28 +122,28 @@ const formatShipMethodSimple = (method) => {
|
|||||||
|
|
||||||
// Loading State Component
|
// Loading State Component
|
||||||
const LoadingState = () => (
|
const LoadingState = () => (
|
||||||
<div className="divide-y divide-border/50">
|
<div className="divide-y divide-[#f5f3f0]">
|
||||||
{[...Array(8)].map((_, i) => (
|
{[...Array(8)].map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-3 p-4 hover:bg-muted/50 transition-colors">
|
<div key={i} className="flex items-center gap-3 px-3 py-3">
|
||||||
<div className="shrink-0">
|
<div className="shrink-0">
|
||||||
<Skeleton className="h-10 w-10 rounded-full bg-muted" />
|
<Skeleton className="h-8 w-8 rounded-md bg-[#f0eeea]" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 space-y-2">
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-4 w-24 bg-muted rounded-sm" />
|
<Skeleton className="h-4 w-24 bg-[#f0eeea] rounded-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Skeleton className="h-4 w-48 bg-muted rounded-sm" />
|
<Skeleton className="h-4 w-48 bg-[#f0eeea] rounded-sm" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1.5 items-center flex-wrap">
|
<div className="flex gap-1.5 items-center flex-wrap">
|
||||||
<Skeleton className="h-5 w-16 bg-muted rounded-md" />
|
<Skeleton className="h-5 w-16 bg-[#f0eeea] rounded-md" />
|
||||||
<Skeleton className="h-5 w-20 bg-muted rounded-md" />
|
<Skeleton className="h-5 w-20 bg-[#f0eeea] rounded-md" />
|
||||||
<Skeleton className="h-5 w-14 bg-muted rounded-md" />
|
<Skeleton className="h-5 w-14 bg-[#f0eeea] rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
|
<Skeleton className="h-4 w-16 bg-[#f0eeea] rounded-sm" />
|
||||||
<Skeleton className="h-4 w-4 bg-muted rounded-full" />
|
<Skeleton className="h-4 w-4 bg-[#f0eeea] rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -177,10 +153,10 @@ const LoadingState = () => (
|
|||||||
// Empty State Component
|
// Empty State Component
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
<div className="h-full flex flex-col items-center justify-center py-16 px-4 text-center">
|
<div className="h-full flex flex-col items-center justify-center py-16 px-4 text-center">
|
||||||
<div className="bg-muted rounded-full p-3 mb-4">
|
<div className="bg-[#f0eeea] rounded-full p-3 mb-4">
|
||||||
<Activity className="h-8 w-8 text-muted-foreground" />
|
<Activity className="h-8 w-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-lg font-medium text-foreground mb-2">
|
<h3 className="text-sm font-medium text-foreground mb-1">
|
||||||
No activity yet today
|
No activity yet today
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`${TYPOGRAPHY.cardDescription} max-w-sm`}>
|
<p className={`${TYPOGRAPHY.cardDescription} max-w-sm`}>
|
||||||
@@ -193,37 +169,37 @@ const EmptyState = () => (
|
|||||||
const OrderStatusTags = ({ details }) => (
|
const OrderStatusTags = ({ details }) => (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{details.HasPreorder && (
|
{details.HasPreorder && (
|
||||||
<span className="px-2 py-1 bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 rounded-full text-xs cursor-help">
|
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
|
||||||
Includes Pre-order
|
Includes Pre-order
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{details.LocalPickup && (
|
{details.LocalPickup && (
|
||||||
<span className="px-2 py-1 bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300 rounded-full text-xs cursor-help">
|
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
|
||||||
Local Pickup
|
Local Pickup
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{details.IsOnHold && (
|
{details.IsOnHold && (
|
||||||
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 rounded-full text-xs cursor-help">
|
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
|
||||||
On Hold
|
On Hold
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{details.HasDigiItem && (
|
{details.HasDigiItem && (
|
||||||
<span className="px-2 py-1 bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 rounded-full text-xs cursor-help">
|
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
|
||||||
Digital Items
|
Digital Items
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{details.HasNotions && (
|
{details.HasNotions && (
|
||||||
<span className="px-2 py-1 bg-pink-100 dark:bg-pink-900/20 text-pink-800 dark:text-pink-300 rounded-full text-xs cursor-help">
|
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
|
||||||
Includes Notions
|
Includes Notions
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{details.HasDigitalGC && (
|
{details.HasDigitalGC && (
|
||||||
<span className="px-2 py-1 bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300 rounded-full text-xs cursor-help">
|
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
|
||||||
Gift Card
|
Gift Card
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{details.StillOwes && (
|
{details.StillOwes && (
|
||||||
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300 rounded-full text-xs cursor-help">
|
<span className="px-2 py-1 bg-[#f5f3f0] text-[#7c7870] rounded-full text-xs font-medium cursor-help">
|
||||||
Payment Due
|
Payment Due
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -231,7 +207,7 @@ const OrderStatusTags = ({ details }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ProductCard = ({ product }) => (
|
const ProductCard = ({ product }) => (
|
||||||
<div className="p-3 bg-muted/50 rounded-lg mb-3 hover:bg-muted transition-colors">
|
<div className="p-3 bg-[#faf9f7] rounded-xl mb-3 hover:bg-[#f0eeea] transition-colors">
|
||||||
<div className="flex items-start space-x-3">
|
<div className="flex items-start space-x-3">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
@@ -239,7 +215,7 @@ const ProductCard = ({ product }) => (
|
|||||||
{product.ProductName || "Unnamed Product"}
|
{product.ProductName || "Unnamed Product"}
|
||||||
</p>
|
</p>
|
||||||
{product.ItemStatus === "Pre-Order" && (
|
{product.ItemStatus === "Pre-Order" && (
|
||||||
<span className="px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded-full text-xs cursor-help">
|
<span className="px-2 py-0.5 bg-[#eae6f6] text-[#4a3f9e] rounded-full text-xs cursor-help">
|
||||||
Pre-order
|
Pre-order
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -280,28 +256,28 @@ const PromotionalInfo = ({ details }) => {
|
|||||||
if (!details?.PromosUsedReg?.length && !details?.PointsDiscount) return null;
|
if (!details?.PromosUsedReg?.length && !details?.PointsDiscount) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 p-3 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
<div className="mt-4 p-3 bg-[#e6f3ee] rounded-xl">
|
||||||
<h4 className="text-sm font-medium text-green-800 dark:text-green-400 mb-2">
|
<h4 className="text-sm font-semibold text-[#237a4d] mb-2">
|
||||||
<span className="cursor-help">Savings Applied</span>
|
<span className="cursor-help">Savings Applied</span>
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Array.isArray(details.PromosUsedReg) &&
|
{Array.isArray(details.PromosUsedReg) &&
|
||||||
details.PromosUsedReg.map(([code, amount], index) => (
|
details.PromosUsedReg.map(([code, amount], index) => (
|
||||||
<div key={index} className="flex justify-between text-sm">
|
<div key={index} className="flex justify-between text-sm">
|
||||||
<span className="font-mono text-green-700 dark:text-green-300 cursor-help">
|
<span className="font-mono text-[#237a4d] cursor-help">
|
||||||
{code}
|
{code}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-green-700 dark:text-green-300 cursor-help">
|
<span className="font-medium text-[#237a4d] cursor-help">
|
||||||
-{formatCurrency(amount)}
|
-{formatCurrency(amount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{details.PointsDiscount > 0 && (
|
{details.PointsDiscount > 0 && (
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-green-700 dark:text-green-300 cursor-help">
|
<span className="text-[#237a4d] cursor-help">
|
||||||
Points Discount
|
Points Discount
|
||||||
</span>
|
</span>
|
||||||
<span className="font-medium text-green-700 dark:text-green-300 cursor-help">
|
<span className="font-medium text-[#237a4d] cursor-help">
|
||||||
-{formatCurrency(details.PointsDiscount)}
|
-{formatCurrency(details.PointsDiscount)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -312,7 +288,7 @@ const PromotionalInfo = ({ details }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const OrderSummary = ({ details }) => (
|
const OrderSummary = ({ details }) => (
|
||||||
<div className="bg-muted/50 p-4 rounded-lg space-y-3">
|
<div className="bg-[#faf9f7] p-4 rounded-xl space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-medium text-muted-foreground mb-2 cursor-help">
|
<h4 className="text-sm font-medium text-muted-foreground mb-2 cursor-help">
|
||||||
@@ -326,13 +302,13 @@ const OrderSummary = ({ details }) => (
|
|||||||
<span>{formatCurrency(details.Subtotal)}</span>
|
<span>{formatCurrency(details.Subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
{details.PointsDiscount > 0 && (
|
{details.PointsDiscount > 0 && (
|
||||||
<div className="flex justify-between text-green-600">
|
<div className="flex justify-between text-[#237a4d]">
|
||||||
<span className="cursor-help">Points Discount</span>
|
<span className="cursor-help">Points Discount</span>
|
||||||
<span>-{formatCurrency(details.PointsDiscount)}</span>
|
<span>-{formatCurrency(details.PointsDiscount)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{details.TotalDiscounts > 0 && (
|
{details.TotalDiscounts > 0 && (
|
||||||
<div className="flex justify-between text-green-600">
|
<div className="flex justify-between text-[#237a4d]">
|
||||||
<span className="cursor-help">Discounts</span>
|
<span className="cursor-help">Discounts</span>
|
||||||
<span>-{formatCurrency(details.TotalDiscounts)}</span>
|
<span>-{formatCurrency(details.TotalDiscounts)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,12 +334,12 @@ const OrderSummary = ({ details }) => (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-3 border-t border-border">
|
<div className="pt-3 border-t border-[#f0eeea]">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-sm font-medium">Total</span>
|
<span className="text-sm font-medium">Total</span>
|
||||||
{details.TotalSavings > 0 && (
|
{details.TotalSavings > 0 && (
|
||||||
<div className="text-xs text-green-600 cursor-help">
|
<div className="text-xs text-[#237a4d] cursor-help">
|
||||||
You saved {formatCurrency(details.TotalSavings)}
|
You saved {formatCurrency(details.TotalSavings)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -407,7 +383,7 @@ const ShippingInfo = ({ details }) => (
|
|||||||
<div className="font-medium cursor-help">
|
<div className="font-medium cursor-help">
|
||||||
{formatShipMethod(details.ShipMethod)}
|
{formatShipMethod(details.ShipMethod)}
|
||||||
</div>
|
</div>
|
||||||
<div className="font-mono text-blue-600 dark:text-blue-400 cursor-pointer hover:underline">
|
<div className="font-mono text-[#3f7fae] cursor-pointer hover:underline">
|
||||||
{details.TrackingNumber}
|
{details.TrackingNumber}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,9 +401,9 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
|
|
||||||
const dialogInner = (
|
const dialogInner = (
|
||||||
<>
|
<>
|
||||||
<DialogHeader className="border-b border-border px-6 py-4">
|
<DialogHeader className="border-b border-[#f0eeea] px-6 py-4">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
{Icon && <Icon className={`h-5 w-5 ${eventType.textColor}`} />}
|
{Icon && <Icon className="h-5 w-5 text-[#7c7870]" />}
|
||||||
<DialogTitle className="text-lg font-semibold">{eventType.label}</DialogTitle>
|
<DialogTitle className="text-lg font-semibold">{eventType.label}</DialogTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -484,7 +460,7 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex flex-wrap gap-2">
|
<CardContent className="flex flex-wrap gap-2">
|
||||||
{details.IsOnHold && (
|
{details.IsOnHold && (
|
||||||
<Badge variant="secondary" className="bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
<Badge variant="secondary" className="bg-[#eae6f6] text-[#4a3f9e]">
|
||||||
On Hold
|
On Hold
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -546,14 +522,14 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
<span className="font-medium">{formatCurrency(details.SalesTax)}</span>
|
<span className="font-medium">{formatCurrency(details.SalesTax)}</span>
|
||||||
</div>
|
</div>
|
||||||
{details.PointsDiscount > 0 && (
|
{details.PointsDiscount > 0 && (
|
||||||
<div className="flex justify-between text-sm text-green-600 dark:text-green-400">
|
<div className="flex justify-between text-sm text-[#237a4d]">
|
||||||
<span>Points Discount</span>
|
<span>Points Discount</span>
|
||||||
<span className="font-medium">-{formatCurrency(details.PointsDiscount)}</span>
|
<span className="font-medium">-{formatCurrency(details.PointsDiscount)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{Array.isArray(details.PromosUsedReg) &&
|
{Array.isArray(details.PromosUsedReg) &&
|
||||||
details.PromosUsedReg.map(([code, amount], i) => (
|
details.PromosUsedReg.map(([code, amount], i) => (
|
||||||
<div key={i} className="flex justify-between text-sm text-green-600 dark:text-green-400">
|
<div key={i} className="flex justify-between text-sm text-[#237a4d]">
|
||||||
<span>{code}</span>
|
<span>{code}</span>
|
||||||
<span className="font-medium">-{formatCurrency(amount)}</span>
|
<span className="font-medium">-{formatCurrency(amount)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -581,7 +557,7 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
<img
|
<img
|
||||||
src={item.ImgThumb}
|
src={item.ImgThumb}
|
||||||
alt={item.ProductName}
|
alt={item.ProductName}
|
||||||
className="w-16 h-16 object-cover rounded bg-muted"
|
className="w-16 h-16 object-cover rounded-lg bg-[#faf9f7]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -592,7 +568,7 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
{item.Quantity}x @ {formatCurrency(item.ItemPrice)}
|
{item.Quantity}x @ {formatCurrency(item.ItemPrice)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm font-medium text-green-600 dark:text-green-400 shrink-0">
|
<p className="text-sm font-medium text-[#237a4d] shrink-0">
|
||||||
{formatCurrency(item.RowTotal)}
|
{formatCurrency(item.RowTotal)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -627,7 +603,7 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
{event.event_properties?.ShippedBy && (
|
{event.event_properties?.ShippedBy && (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm text-muted-foreground"> • </span>
|
<span className="text-sm text-muted-foreground"> • </span>
|
||||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
|
<span className="text-sm font-medium text-[#3f7fae]">Shipped by {event.event_properties.ShippedBy}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -706,7 +682,7 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
<img
|
<img
|
||||||
src={item.ImgThumb}
|
src={item.ImgThumb}
|
||||||
alt={item.ProductName}
|
alt={item.ProductName}
|
||||||
className="w-16 h-16 object-cover rounded bg-muted"
|
className="w-16 h-16 object-cover rounded-lg bg-[#faf9f7]"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -784,7 +760,7 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
href={details.url}
|
href={details.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="text-sm text-blue-600 dark:text-blue-400 hover:underline inline-flex items-center gap-1"
|
className="text-sm text-[#3f7fae] hover:underline inline-flex items-center gap-1"
|
||||||
>
|
>
|
||||||
Read More
|
Read More
|
||||||
<ChevronRight className="h-4 w-4" />
|
<ChevronRight className="h-4 w-4" />
|
||||||
@@ -802,8 +778,8 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent className={scale
|
<DialogContent className={scale
|
||||||
? "w-[80vw] h-[80vh] max-w-none p-0 overflow-hidden"
|
? "w-[80vw] h-[80vh] max-w-none p-0 overflow-hidden rounded-[14px] border-[#e7e5e1]"
|
||||||
: "max-w-2xl max-h-[85vh] overflow-hidden flex flex-col"
|
: "max-w-2xl max-h-[85vh] overflow-hidden flex flex-col rounded-[14px] border-[#e7e5e1]"
|
||||||
}>
|
}>
|
||||||
{scale ? (
|
{scale ? (
|
||||||
<div
|
<div
|
||||||
@@ -820,221 +796,115 @@ const EventDialog = ({ event, children, scale }) => {
|
|||||||
|
|
||||||
export { EventDialog };
|
export { EventDialog };
|
||||||
|
|
||||||
const EventCard = ({ event }) => {
|
// Studio feed row: type is a colored dot, details collapse to one muted line,
|
||||||
const eventType = EVENT_TYPES[event.metric_id] || {
|
// order flags get a single amber emphasis instead of a rainbow of badges.
|
||||||
label: "Unknown Event",
|
const EVENT_DOTS = {
|
||||||
color: "bg-slate-500",
|
[METRIC_IDS.PLACED_ORDER]: "#2e8f5b",
|
||||||
textColor: "text-muted-foreground",
|
[METRIC_IDS.SHIPPED_ORDER]: "#3f7fae",
|
||||||
};
|
[METRIC_IDS.ACCOUNT_CREATED]: "#6b5fc7",
|
||||||
|
[METRIC_IDS.CANCELED_ORDER]: "#b3503f",
|
||||||
|
[METRIC_IDS.PAYMENT_REFUNDED]: "#c9973d",
|
||||||
|
[METRIC_IDS.NEW_BLOG_POST]: "#a09b92",
|
||||||
|
};
|
||||||
|
|
||||||
const Icon = EVENT_ICONS[event.metric_id] || Package;
|
const EventCard = ({ event }) => {
|
||||||
|
const eventType = EVENT_TYPES[event.metric_id] || { label: "Event" };
|
||||||
const details = event.event_properties || {};
|
const details = event.event_properties || {};
|
||||||
|
|
||||||
const datetime = event.attributes?.datetime || event.datetime || event.event_properties?.datetime;
|
const datetime = event.attributes?.datetime || event.datetime || event.event_properties?.datetime;
|
||||||
const timestamp = datetime ? new Date(datetime) : null;
|
const timestamp = datetime ? new Date(datetime) : null;
|
||||||
const isValidDate = timestamp && !isNaN(timestamp.getTime());
|
const isValidDate = timestamp && !isNaN(timestamp.getTime());
|
||||||
|
const dot = EVENT_DOTS[event.metric_id] || "#a09b92";
|
||||||
|
|
||||||
|
let name = "";
|
||||||
|
let parts = [];
|
||||||
|
let flags = [];
|
||||||
|
|
||||||
|
switch (event.metric_id) {
|
||||||
|
case METRIC_IDS.PLACED_ORDER:
|
||||||
|
name = toTitleCase(details.ShippingName);
|
||||||
|
parts = [`#${details.OrderId}`, formatCurrency(details.TotalAmount)];
|
||||||
|
flags = [
|
||||||
|
details.IsOnHold && "On Hold",
|
||||||
|
details.OnHoldReleased && "Hold Released",
|
||||||
|
details.StillOwes && "Owes",
|
||||||
|
details.LocalPickup && "Local",
|
||||||
|
details.HasPreorder && "Pre-order",
|
||||||
|
details.HasNotions && "Notions",
|
||||||
|
(details.OnlyDigitalGC || details.HasDigitalGC) && "eGift Card",
|
||||||
|
(details.HasDigiItem || details.OnlyDigiItem) && "Digital",
|
||||||
|
].filter(Boolean);
|
||||||
|
break;
|
||||||
|
case METRIC_IDS.SHIPPED_ORDER:
|
||||||
|
name = toTitleCase(details.ShippingName);
|
||||||
|
parts = [
|
||||||
|
`#${details.OrderId}`,
|
||||||
|
formatShipMethodSimple(details.ShipMethod),
|
||||||
|
details.ShippedBy && `by ${details.ShippedBy}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
break;
|
||||||
|
case METRIC_IDS.ACCOUNT_CREATED:
|
||||||
|
name =
|
||||||
|
details.FirstName && details.LastName
|
||||||
|
? `${toTitleCase(details.FirstName)} ${toTitleCase(details.LastName)}`
|
||||||
|
: "New customer";
|
||||||
|
parts = [details.EmailAddress].filter(Boolean);
|
||||||
|
break;
|
||||||
|
case METRIC_IDS.CANCELED_ORDER:
|
||||||
|
name = toTitleCase(details.ShippingName);
|
||||||
|
parts = [
|
||||||
|
`#${details.OrderId}`,
|
||||||
|
formatCurrency(details.TotalAmount),
|
||||||
|
details.CancelReason,
|
||||||
|
].filter(Boolean);
|
||||||
|
break;
|
||||||
|
case METRIC_IDS.PAYMENT_REFUNDED:
|
||||||
|
name = toTitleCase(details.ShippingName);
|
||||||
|
parts = [
|
||||||
|
`#${details.FromOrder}`,
|
||||||
|
formatCurrency(details.PaymentAmount),
|
||||||
|
details.PaymentName && `via ${details.PaymentName}`,
|
||||||
|
].filter(Boolean);
|
||||||
|
break;
|
||||||
|
case METRIC_IDS.NEW_BLOG_POST:
|
||||||
|
name = details.title;
|
||||||
|
parts = [details.description].filter(Boolean);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EventDialog event={event}>
|
<EventDialog event={event}>
|
||||||
<button className="w-full focus:outline-none text-left">
|
<button className="w-full text-left focus:outline-none focus-visible:bg-[#faf9f7]">
|
||||||
<div className="flex items-center gap-3 p-4 hover:bg-muted/50 transition-colors border-b border-border/50 last:border-b-0">
|
<div className="flex items-start gap-2.5 border-b border-[#f5f3f0] px-4 py-2.5 transition-colors last:border-b-0 hover:bg-[#faf9f7]">
|
||||||
<div className={`shrink-0 w-10 h-10 rounded-full ${eventType.color} bg-opacity-10 dark:bg-opacity-20 flex items-center justify-center`}>
|
<span
|
||||||
<Icon className="h-5 w-5 text-foreground" />
|
className="mt-[7px] inline-block h-2 w-2 shrink-0 rounded-full"
|
||||||
</div>
|
style={{ background: dot }}
|
||||||
|
/>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<span className={`${eventType.textColor} text-sm font-medium`}>
|
<span className="truncate text-[13px] font-semibold text-[#2b2925]">
|
||||||
{eventType.label}
|
{name || eventType.label}
|
||||||
</span>
|
</span>
|
||||||
|
{isValidDate && (
|
||||||
|
<time
|
||||||
|
className="shrink-0 text-[11px] text-[#a09b92]"
|
||||||
|
dateTime={timestamp.toISOString()}
|
||||||
|
>
|
||||||
|
{format(timestamp, "h:mm a")}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[11.5px] text-[#7c7870]">
|
||||||
|
{eventType.label}
|
||||||
|
{parts.map((part, i) => (
|
||||||
|
<span key={i}> · {part}</span>
|
||||||
|
))}
|
||||||
|
{flags.length > 0 && (
|
||||||
|
<span className="font-semibold text-[#a87a24]"> · {flags.join(" · ")}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{event.metric_id === METRIC_IDS.PLACED_ORDER && (
|
|
||||||
<>
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
{toTitleCase(details.ShippingName)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
#{details.OrderId}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">•</span>
|
|
||||||
<span className="font-medium text-green-600 dark:text-green-400">
|
|
||||||
{formatCurrency(details.TotalAmount)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1.5 items-center flex-wrap mt-2">
|
|
||||||
{details.IsOnHold && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-blue-100 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 text-xs py-0"
|
|
||||||
>
|
|
||||||
On Hold
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{details.OnHoldReleased && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
|
|
||||||
>
|
|
||||||
Hold Released
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{details.StillOwes && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-xs py-0"
|
|
||||||
>
|
|
||||||
Owes
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{details.LocalPickup && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 text-xs py-0"
|
|
||||||
>
|
|
||||||
Local
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{details.HasPreorder && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-purple-100 dark:bg-purple-900/20 text-purple-700 dark:text-purple-300 text-xs py-0"
|
|
||||||
>
|
|
||||||
Pre-order
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{details.HasNotions && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-yellow-100 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-300 text-xs py-0"
|
|
||||||
>
|
|
||||||
Notions
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{(details.OnlyDigitalGC || details.HasDigitalGC) && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-pink-100 dark:bg-pink-900/20 text-pink-700 dark:text-pink-300 text-xs py-0"
|
|
||||||
>
|
|
||||||
eGift Card
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{(details.HasDigiItem || details.OnlyDigiItem) && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="bg-indigo-100 dark:bg-indigo-900/20 text-indigo-700 dark:text-indigo-300 text-xs py-0"
|
|
||||||
>
|
|
||||||
Digital
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.metric_id === METRIC_IDS.SHIPPED_ORDER && (
|
|
||||||
<>
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
{toTitleCase(details.ShippingName)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">•</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
#{details.OrderId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{formatShipMethodSimple(details.ShipMethod)}
|
|
||||||
{event.event_properties?.ShippedBy && (
|
|
||||||
<>
|
|
||||||
<span className="text-sm text-muted-foreground"> • </span>
|
|
||||||
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">Shipped by {event.event_properties.ShippedBy}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.metric_id === METRIC_IDS.ACCOUNT_CREATED && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
{details.FirstName && details.LastName
|
|
||||||
? `${toTitleCase(details.FirstName)} ${toTitleCase(
|
|
||||||
details.LastName
|
|
||||||
)}`
|
|
||||||
: "New Customer"}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{details.EmailAddress}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.metric_id === METRIC_IDS.CANCELED_ORDER && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
{toTitleCase(details.ShippingName)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">•</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
#{details.OrderId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{formatCurrency(details.TotalAmount)} • {details.CancelReason}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.metric_id === METRIC_IDS.PAYMENT_REFUNDED && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
{toTitleCase(details.ShippingName)}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm text-muted-foreground">•</span>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
#{details.FromOrder}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{formatCurrency(details.PaymentAmount)} via{" "}
|
|
||||||
{details.PaymentName}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.metric_id === METRIC_IDS.NEW_BLOG_POST && (
|
|
||||||
<div className="mt-1">
|
|
||||||
<div className="text-sm font-medium text-foreground">
|
|
||||||
{details.title}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-muted-foreground line-clamp-1">
|
|
||||||
{details.description}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
|
||||||
{isValidDate && (
|
|
||||||
<time
|
|
||||||
className="text-sm text-muted-foreground"
|
|
||||||
dateTime={timestamp.toISOString()}
|
|
||||||
>
|
|
||||||
{format(timestamp, "h:mm a")}
|
|
||||||
</time>
|
|
||||||
)}
|
|
||||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -1248,302 +1118,51 @@ const EventFeed = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const EventTypeTooltipContent = () => (
|
const TYPE_FILTERS = [
|
||||||
<div className="grid gap-2">
|
{ id: METRIC_IDS.PLACED_ORDER, label: "Orders" },
|
||||||
<div className="flex items-center justify-between gap-4">
|
{ id: METRIC_IDS.SHIPPED_ORDER, label: "Shipped" },
|
||||||
<span className="flex items-center gap-2">
|
{ id: METRIC_IDS.ACCOUNT_CREATED, label: "Accounts" },
|
||||||
<Package className="h-4 w-4" />
|
{ id: METRIC_IDS.CANCELED_ORDER, label: "Canceled" },
|
||||||
Orders
|
{ id: METRIC_IDS.PAYMENT_REFUNDED, label: "Refunds" },
|
||||||
</span>
|
{ id: METRIC_IDS.NEW_BLOG_POST, label: "Blog" },
|
||||||
<Badge variant="secondary" className="bg-muted">
|
];
|
||||||
{counts.eventTypes[METRIC_IDS.PLACED_ORDER].toLocaleString()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Truck className="h-4 w-4" />
|
|
||||||
Shipments
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="bg-muted">
|
|
||||||
{counts.eventTypes[METRIC_IDS.SHIPPED_ORDER].toLocaleString()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<UserPlus className="h-4 w-4" />
|
|
||||||
Accounts
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="bg-muted">
|
|
||||||
{counts.eventTypes[METRIC_IDS.ACCOUNT_CREATED].toLocaleString()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<XCircle className="h-4 w-4" />
|
|
||||||
Cancellations
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="bg-muted">
|
|
||||||
{counts.eventTypes[METRIC_IDS.CANCELED_ORDER].toLocaleString()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<DollarSign className="h-4 w-4" />
|
|
||||||
Refunds
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="bg-muted">
|
|
||||||
{counts.eventTypes[METRIC_IDS.PAYMENT_REFUNDED].toLocaleString()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
Blog Posts
|
|
||||||
</span>
|
|
||||||
<Badge variant="secondary" className="bg-muted">
|
|
||||||
{counts.eventTypes[METRIC_IDS.NEW_BLOG_POST].toLocaleString()}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`flex flex-col h-full ${CARD_STYLES.base} w-full`}>
|
<Card className={`flex flex-col h-full ${CARD_STYLES.base} w-full`}>
|
||||||
<CardHeader className="p-6 pb-2">
|
<CardHeader className="space-y-2 border-b border-[#f0eeea] px-4 pb-3.5 pt-4">
|
||||||
<div className="flex justify-between items-start">
|
<CardTitle className="text-[12.5px] font-semibold tracking-tight text-[#2b2925]">
|
||||||
<div>
|
{title}
|
||||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
|
</CardTitle>
|
||||||
{lastUpdate && (
|
|
||||||
<CardDescription className={TYPOGRAPHY.cardDescription}>
|
|
||||||
Last updated {format(lastUpdate, "h:mm a")}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!error && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={activeEventTypes[METRIC_IDS.PLACED_ORDER] ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEventTypeClick(METRIC_IDS.PLACED_ORDER)}
|
|
||||||
className="h-8 w-8 p-0 rounded-md"
|
|
||||||
>
|
|
||||||
<Package className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<EventTypeTooltipContent />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
{/* Event-type filter pills — dot doubles as the type legend */}
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={activeEventTypes[METRIC_IDS.SHIPPED_ORDER] ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEventTypeClick(METRIC_IDS.SHIPPED_ORDER)}
|
|
||||||
className="h-8 w-8 p-0 rounded-md"
|
|
||||||
>
|
|
||||||
<Truck className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<EventTypeTooltipContent />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={activeEventTypes[METRIC_IDS.ACCOUNT_CREATED] ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEventTypeClick(METRIC_IDS.ACCOUNT_CREATED)}
|
|
||||||
className="h-8 w-8 p-0 rounded-md"
|
|
||||||
>
|
|
||||||
<UserPlus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<EventTypeTooltipContent />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={activeEventTypes[METRIC_IDS.CANCELED_ORDER] ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEventTypeClick(METRIC_IDS.CANCELED_ORDER)}
|
|
||||||
className="h-8 w-8 p-0 rounded-md"
|
|
||||||
>
|
|
||||||
<XCircle className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<EventTypeTooltipContent />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={activeEventTypes[METRIC_IDS.PAYMENT_REFUNDED] ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEventTypeClick(METRIC_IDS.PAYMENT_REFUNDED)}
|
|
||||||
className="h-8 w-8 p-0 rounded-md"
|
|
||||||
>
|
|
||||||
<DollarSign className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<EventTypeTooltipContent />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant={activeEventTypes[METRIC_IDS.NEW_BLOG_POST] ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleEventTypeClick(METRIC_IDS.NEW_BLOG_POST)}
|
|
||||||
className="h-8 w-8 p-0 rounded-md"
|
|
||||||
>
|
|
||||||
<FileText className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<EventTypeTooltipContent />
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Order Property Filters - update styling */}
|
|
||||||
{!error && (
|
{!error && (
|
||||||
<div className="flex flex-wrap gap-2 justify-center mt-4 pt-1">
|
<div className="flex flex-wrap items-center gap-1">
|
||||||
{counts.orderProperties.hasPreorder > 0 && (
|
{TYPE_FILTERS.map(({ id, label }) => (
|
||||||
<Badge
|
<button
|
||||||
variant="secondary"
|
key={id}
|
||||||
onClick={() => handleOrderPropertyClick('hasPreorder')}
|
type="button"
|
||||||
className={`${
|
onClick={() => handleEventTypeClick(id)}
|
||||||
orderFilters.hasPreorder
|
className={`flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-[11px] font-medium transition-colors ${
|
||||||
? 'bg-purple-800 text-purple-200 hover:bg-purple-700'
|
activeEventTypes[id]
|
||||||
: 'bg-purple-100 dark:bg-purple-900/20 text-purple-800 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/20'
|
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
|
||||||
} cursor-pointer rounded-md`}
|
: "text-[#a09b92] hover:bg-[#f5f3f0] hover:text-[#2b2925]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Pre-order ({counts.orderProperties.hasPreorder})
|
<span
|
||||||
</Badge>
|
className="inline-block h-1.5 w-1.5 rounded-full"
|
||||||
)}
|
style={{ background: activeEventTypes[id] ? EVENT_DOTS[id] : "#d8d4cd" }}
|
||||||
{counts.orderProperties.localPickup > 0 && (
|
/>
|
||||||
<Badge
|
{label}
|
||||||
variant="secondary"
|
{counts.eventTypes[id] > 0 && (
|
||||||
onClick={() => handleOrderPropertyClick('localPickup')}
|
<span className="tabular-nums text-[#a09b92]">{counts.eventTypes[id]}</span>
|
||||||
className={`${
|
)}
|
||||||
orderFilters.localPickup
|
</button>
|
||||||
? 'bg-green-800 text-green-200 hover:bg-green-700'
|
))}
|
||||||
: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/20'
|
|
||||||
} cursor-pointer rounded-md`}
|
|
||||||
>
|
|
||||||
Local ({counts.orderProperties.localPickup})
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{counts.orderProperties.isOnHold > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => handleOrderPropertyClick('isOnHold')}
|
|
||||||
className={`${
|
|
||||||
orderFilters.isOnHold
|
|
||||||
? 'bg-blue-800 text-blue-200 hover:bg-blue-700'
|
|
||||||
: 'bg-blue-100 dark:bg-blue-900/20 text-blue-800 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/20'
|
|
||||||
} cursor-pointer rounded-md`}
|
|
||||||
>
|
|
||||||
On Hold ({counts.orderProperties.isOnHold})
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{counts.orderProperties.onHoldReleased > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => handleOrderPropertyClick('onHoldReleased')}
|
|
||||||
className={`${
|
|
||||||
orderFilters.onHoldReleased
|
|
||||||
? 'bg-green-800 text-green-200 hover:bg-green-700'
|
|
||||||
: 'bg-green-100 dark:bg-green-900/20 text-green-800 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/20'
|
|
||||||
} cursor-pointer rounded-md`}
|
|
||||||
>
|
|
||||||
Hold Released ({counts.orderProperties.onHoldReleased})
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{counts.orderProperties.hasDigiItem > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => handleOrderPropertyClick('hasDigiItem')}
|
|
||||||
className={`${
|
|
||||||
orderFilters.hasDigiItem
|
|
||||||
? 'bg-indigo-800 text-indigo-200 hover:bg-indigo-700'
|
|
||||||
: 'bg-indigo-100 dark:bg-indigo-900/20 text-indigo-800 dark:text-indigo-300 hover:bg-indigo-100 dark:hover:bg-indigo-900/20'
|
|
||||||
} cursor-pointer rounded-md`}
|
|
||||||
>
|
|
||||||
Digital ({counts.orderProperties.hasDigiItem})
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{counts.orderProperties.hasNotions > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => handleOrderPropertyClick('hasNotions')}
|
|
||||||
className={`${
|
|
||||||
orderFilters.hasNotions
|
|
||||||
? 'bg-yellow-800 text-yellow-200 hover:bg-yellow-700'
|
|
||||||
: 'bg-yellow-100 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-300 hover:bg-yellow-100 dark:hover:bg-yellow-900/20'
|
|
||||||
} cursor-pointer rounded-md`}
|
|
||||||
>
|
|
||||||
Notions ({counts.orderProperties.hasNotions})
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{counts.orderProperties.hasGiftCard > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => handleOrderPropertyClick('hasGiftCard')}
|
|
||||||
className={`${
|
|
||||||
orderFilters.hasGiftCard
|
|
||||||
? 'bg-pink-800 text-pink-200 hover:bg-pink-700'
|
|
||||||
: 'bg-pink-100 dark:bg-pink-900/20 text-pink-800 dark:text-pink-300 hover:bg-pink-100 dark:hover:bg-pink-900/20'
|
|
||||||
} cursor-pointer rounded-md`}
|
|
||||||
>
|
|
||||||
eGift Card ({counts.orderProperties.hasGiftCard})
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{counts.orderProperties.stillOwes > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => handleOrderPropertyClick('stillOwes')}
|
|
||||||
className={`${
|
|
||||||
orderFilters.stillOwes
|
|
||||||
? 'bg-red-800 text-red-200 hover:bg-red-700'
|
|
||||||
: 'bg-red-100 dark:bg-red-900/20 text-red-800 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/20'
|
|
||||||
} cursor-pointer rounded-md`}
|
|
||||||
>
|
|
||||||
Owes ({counts.orderProperties.stillOwes})
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="px-0 pb-6 pt-0 md:px-6 flex-1 overflow-hidden -mt-2">
|
<CardContent className="px-0 pb-4 pt-0 md:px-4 flex-1 overflow-hidden">
|
||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
{loading && !events.length ? (
|
{loading && !events.length ? (
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
@@ -1552,7 +1171,7 @@ const EventFeed = ({
|
|||||||
) : !filteredEvents || filteredEvents.length === 0 ? (
|
) : !filteredEvents || filteredEvents.length === 0 ? (
|
||||||
<EmptyState />
|
<EmptyState />
|
||||||
) : (
|
) : (
|
||||||
<div className="divide-y divide-border/50">
|
<div>
|
||||||
{filteredEvents.map((event) => (
|
{filteredEvents.map((event) => (
|
||||||
<EventCard key={event.id} event={event} />
|
<EventCard key={event.id} event={event} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
Area,
|
Area,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
Legend,
|
|
||||||
Line,
|
Line,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -52,7 +51,7 @@ import {
|
|||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
METRIC_COLORS,
|
FINANCIAL_COLORS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
type ComparisonValue = {
|
type ComparisonValue = {
|
||||||
@@ -126,13 +125,13 @@ type ChartPoint = {
|
|||||||
isFuture: boolean;
|
isFuture: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chart colors mapped from semantic METRIC_COLORS tokens
|
// Chart colors from the semantic FINANCIAL_COLORS tokens (Sorbet Studio)
|
||||||
const chartColors: Record<ChartSeriesKey, string> = {
|
const chartColors: Record<ChartSeriesKey, string> = {
|
||||||
income: METRIC_COLORS.orders, // Blue - revenue/income streams
|
income: FINANCIAL_COLORS.income, // Coral - revenue/income streams
|
||||||
cogs: METRIC_COLORS.expense, // Orange - costs/expenses
|
cogs: FINANCIAL_COLORS.expense, // Studio amber - costs/expenses
|
||||||
cogsPercentage: "#f97316", // Orange-500 - slightly brighter for percentage line
|
cogsPercentage: FINANCIAL_COLORS.expense, // Same amber — the dashed stroke carries the distinction
|
||||||
profit: METRIC_COLORS.profit, // Green - profit metrics
|
profit: FINANCIAL_COLORS.profit, // Teal - profit metrics
|
||||||
margin: METRIC_COLORS.aov, // Violet - percentage/derived metrics
|
margin: FINANCIAL_COLORS.margin, // Violet - percentage/derived metrics
|
||||||
};
|
};
|
||||||
|
|
||||||
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
|
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
|
||||||
@@ -1109,7 +1108,7 @@ const FinancialOverview = () => {
|
|||||||
<>
|
<>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="h-9" disabled={loading || !detailRows.length}>
|
<Button variant="outline" size="sm" className="h-8 rounded-full border-[#e7e5e1] text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]" disabled={loading || !detailRows.length}>
|
||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
@@ -1123,8 +1122,9 @@ const FinancialOverview = () => {
|
|||||||
{SERIES_DEFINITIONS.map((series) => (
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
<Button
|
<Button
|
||||||
key={series.key}
|
key={series.key}
|
||||||
variant={metrics[series.key] ? "default" : "outline"}
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics[series.key] ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
|
||||||
onClick={() => toggleMetric(series.key)}
|
onClick={() => toggleMetric(series.key)}
|
||||||
>
|
>
|
||||||
{series.label}
|
{series.label}
|
||||||
@@ -1241,7 +1241,7 @@ const FinancialOverview = () => {
|
|||||||
actions={headerActions}
|
actions={headerActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0 space-y-4">
|
<CardContent className="p-4 pt-3 space-y-3">
|
||||||
{/* Show stats only if not in error state */}
|
{/* Show stats only if not in error state */}
|
||||||
{!error && (
|
{!error && (
|
||||||
loading ? (
|
loading ? (
|
||||||
@@ -1251,47 +1251,52 @@ const FinancialOverview = () => {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show metric toggles only if not in error state */}
|
{/* Metric toggle pills — the pill dot doubles as the series legend */}
|
||||||
{!error && (
|
{!error && (
|
||||||
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
<div className="flex flex-wrap gap-1">
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
{SERIES_DEFINITIONS.map((series) => (
|
<button
|
||||||
<Button
|
key={series.key}
|
||||||
key={series.key}
|
type="button"
|
||||||
variant={metrics[series.key] ? "default" : "outline"}
|
onClick={() => toggleMetric(series.key)}
|
||||||
size="sm"
|
className={
|
||||||
onClick={() => toggleMetric(series.key)}
|
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors " +
|
||||||
>
|
(metrics[series.key]
|
||||||
{series.label}
|
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
|
||||||
</Button>
|
: "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]")
|
||||||
))}
|
}
|
||||||
</div>
|
>
|
||||||
|
<span
|
||||||
<Separator
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
orientation="vertical"
|
style={
|
||||||
className="h-6 hidden sm:block"
|
series.key === "cogsPercentage"
|
||||||
/>
|
? {
|
||||||
<Separator
|
background: "transparent",
|
||||||
orientation="horizontal"
|
border: `1.5px dashed ${metrics[series.key] ? chartColors.cogsPercentage : "#d8d4cd"}`,
|
||||||
className="sm:hidden w-20 my-2"
|
}
|
||||||
/>
|
: {
|
||||||
|
background: metrics[series.key]
|
||||||
<div className="flex items-center gap-2">
|
? chartColors[series.key as ChartSeriesKey]
|
||||||
<div className="text-sm text-muted-foreground">Group By:</div>
|
: "#d8d4cd",
|
||||||
|
}
|
||||||
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
}
|
||||||
<SelectTrigger className="w-[110px]">
|
/>
|
||||||
<SelectValue placeholder="Group By" />
|
{series.label}
|
||||||
</SelectTrigger>
|
</button>
|
||||||
<SelectContent>
|
))}
|
||||||
{GROUP_BY_CHOICES.map((option) => (
|
<span className="mx-1.5 h-4 w-px bg-[#eceae5]" />
|
||||||
<SelectItem key={option.value} value={option.value}>
|
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||||||
{option.label}
|
<SelectTrigger className="h-8 w-auto gap-1.5 rounded-full border-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]">
|
||||||
</SelectItem>
|
<SelectValue placeholder="Group by" />
|
||||||
))}
|
</SelectTrigger>
|
||||||
</SelectContent>
|
<SelectContent className="rounded-xl">
|
||||||
</Select>
|
{GROUP_BY_CHOICES.map((option) => (
|
||||||
</div>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -1308,7 +1313,7 @@ const FinancialOverview = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={`h-[400px] mt-4 ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
<div className={`h-[340px] ${CARD_STYLES.subtle} p-0 relative`}>
|
||||||
{!hasActiveMetrics ? (
|
{!hasActiveMetrics ? (
|
||||||
<DashboardEmptyState
|
<DashboardEmptyState
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
@@ -1317,7 +1322,7 @@ const FinancialOverview = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 5, right: -25, left: 15, bottom: 5 }}>
|
<ComposedChart data={chartData} margin={{ top: 8, right: 0, left: -8, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="financialCogs" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="financialCogs" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={chartColors.cogs} stopOpacity={0.8} />
|
<stop offset="5%" stopColor={chartColors.cogs} stopOpacity={0.8} />
|
||||||
@@ -1354,7 +1359,6 @@ const FinancialOverview = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={<FinancialTooltip />} />
|
<Tooltip content={<FinancialTooltip />} />
|
||||||
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
|
|
||||||
{/* Stacked areas showing revenue breakdown */}
|
{/* Stacked areas showing revenue breakdown */}
|
||||||
{metrics.cogs ? (
|
{metrics.cogs ? (
|
||||||
<Area
|
<Area
|
||||||
@@ -1456,7 +1460,7 @@ function FinancialStatGrid({
|
|||||||
cards: FinancialStatCardConfig[];
|
cards: FinancialStatCardConfig[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
@@ -1482,7 +1486,7 @@ function FinancialStatGrid({
|
|||||||
|
|
||||||
function SkeletonStats() {
|
function SkeletonStats() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,376 +1,39 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { useNow } from "@/hooks/useNow";
|
||||||
import {
|
|
||||||
Calendar,
|
|
||||||
Clock,
|
|
||||||
Sun,
|
|
||||||
Cloud,
|
|
||||||
CloudRain,
|
|
||||||
CloudDrizzle,
|
|
||||||
CloudSnow,
|
|
||||||
CloudLightning,
|
|
||||||
CloudFog,
|
|
||||||
CloudSun,
|
|
||||||
CircleAlert,
|
|
||||||
Tornado,
|
|
||||||
Haze,
|
|
||||||
Moon,
|
|
||||||
Monitor,
|
|
||||||
Wind,
|
|
||||||
Droplets,
|
|
||||||
ThermometerSun,
|
|
||||||
ThermometerSnowflake,
|
|
||||||
Sunrise,
|
|
||||||
Sunset,
|
|
||||||
AlertTriangle,
|
|
||||||
Umbrella,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useScroll } from "@/contexts/DashboardScrollContext";
|
|
||||||
import { useTheme } from "@/components/dashboard/theme/ThemeProvider";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
|
||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
|
||||||
|
|
||||||
const CraftsIcon = () => (
|
const formatDate = (date) =>
|
||||||
<svg viewBox="0 0 2687 3338" className="w-6 h-6" aria-hidden="true">
|
date.toLocaleDateString("en-US", {
|
||||||
<path
|
weekday: "long",
|
||||||
fill="white"
|
month: "long",
|
||||||
d="M911.230469 1807.75C974.730469 1695.5 849.919922 1700.659912 783.610352 1791.25C645.830078 1979.439941 874.950195 2120.310059 1112.429688 2058.800049C1201.44043 2035.72998 1278.759766 2003.080078 1344.580078 1964.159912C1385.389648 1940.040039 1380.900391 1926.060059 1344.580078 1935.139893C1294.040039 1947.800049 1261.69043 1953.73999 1177.700195 1966.97998C1084.719727 1981.669922 832.790039 1984.22998 911.230469 1807.75M1046.799805 1631.389893C1135.280273 1670.419922 1139.650391 1624.129883 1056.980469 1562.070068C925.150391 1463.110107 787.360352 1446.379883 661.950195 1478.280029C265.379883 1579.179932 67.740234 2077.050049 144.099609 2448.399902C357.860352 3487.689941 1934.570313 3457.959961 2143.030273 2467.540039C2204.700195 2174.439941 2141.950195 1852.780029 1917.990234 1665.149902C1773.219727 1543.870117 1575.009766 1536.659912 1403.599609 1591.72998C1380.639648 1599.110107 1381.410156 1616.610107 1403.599609 1612.379883C1571.25 1596.040039 1750.790039 1606 1856.75 1745.280029C2038.769531 1984.459961 2052.570313 2274.080078 1974.629883 2511.209961C1739.610352 3226.25 640.719727 3226.540039 401.719727 2479.26001C308.040039 2186.350098 400.299805 1788.800049 690 1639.100098C785.830078 1589.590088 907.040039 1569.709961 1046.799805 1631.389893Z"
|
day: "numeric",
|
||||||
/>
|
});
|
||||||
<path
|
|
||||||
fill="white"
|
|
||||||
d="M1270.089844 1727.72998C1292.240234 1431.47998 1284.94043 952.430176 1257.849609 717.390137C1235.679688 525.310059 1166.200195 416.189941 1093.629883 349.390137C1157.620117 313.180176 1354.129883 485.680176 1447.830078 603.350098C1790.870117 1034.100098 2235.580078 915.060059 2523.480469 721.129883C2569.120117 680.51001 2592.900391 654.030029 2523.480469 651.339844C2260.400391 615.330078 2115 463.060059 1947.530273 293.890137C1672.870117 16.459961 1143.719727 162.169922 1033.969727 303.040039C999.339844 280.299805 966.849609 265 941.709961 252.419922C787.139648 175.160156 670.049805 223.580078 871.780273 341.569824C962.599609 394.689941 1089.849609 483.48999 1168.230469 799.589844C1222.370117 1018.040039 1230.009766 1423.919922 1242.360352 1728.379883C1247 1761.850098 1264.799805 1759.629883 1270.089844 1727.72998"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
const Header = () => {
|
const greeting = (date) => {
|
||||||
const [currentTime, setCurrentTime] = useState(new Date());
|
const hour = date.getHours();
|
||||||
const [weather, setWeather] = useState(null);
|
if (hour < 12) return "Good morning";
|
||||||
const [forecast, setForecast] = useState(null);
|
if (hour < 17) return "Good afternoon";
|
||||||
const { isStuck } = useScroll();
|
return "Good evening";
|
||||||
const { theme, systemTheme, toggleTheme, setTheme } = useTheme();
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = setInterval(() => {
|
|
||||||
setCurrentTime(new Date());
|
|
||||||
}, 1000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const fetchWeatherData = async () => {
|
|
||||||
try {
|
|
||||||
const API_KEY = import.meta.env.VITE_OPENWEATHER_API_KEY;
|
|
||||||
const [weatherResponse, forecastResponse] = await Promise.all([
|
|
||||||
fetch(
|
|
||||||
`https://api.openweathermap.org/data/2.5/weather?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
|
||||||
),
|
|
||||||
fetch(
|
|
||||||
`https://api.openweathermap.org/data/2.5/forecast?lat=43.63507&lon=-84.18995&appid=${API_KEY}&units=imperial`
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const weatherData = await weatherResponse.json();
|
|
||||||
const forecastData = await forecastResponse.json();
|
|
||||||
|
|
||||||
setWeather(weatherData);
|
|
||||||
|
|
||||||
// Process forecast data to get daily forecasts
|
|
||||||
const dailyForecasts = forecastData.list.reduce((acc, item) => {
|
|
||||||
const date = new Date(item.dt * 1000).toLocaleDateString();
|
|
||||||
if (!acc[date]) {
|
|
||||||
acc[date] = {
|
|
||||||
...item,
|
|
||||||
precipitation: item.rain?.['3h'] || item.snow?.['3h'] || 0,
|
|
||||||
pop: item.pop * 100
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
setForecast(Object.values(dailyForecasts).slice(0, 5));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching weather:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchWeatherData();
|
|
||||||
const weatherTimer = setInterval(fetchWeatherData, 300000);
|
|
||||||
return () => clearInterval(weatherTimer);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getWeatherIcon = (weatherCode, currentTime, small = false) => {
|
|
||||||
if (!weatherCode) return <CircleAlert className={cn(small ? "w-6 h-6" : "w-7 h-7", "text-red-500")} />;
|
|
||||||
|
|
||||||
const code = parseInt(weatherCode, 10);
|
|
||||||
const iconProps = small ? "w-6 h-6" : "w-7 h-7";
|
|
||||||
|
|
||||||
switch (true) {
|
|
||||||
case code >= 200 && code < 300:
|
|
||||||
return <CloudLightning className={cn(iconProps, "text-gray-700")} />;
|
|
||||||
case code >= 300 && code < 500:
|
|
||||||
return <CloudDrizzle className={cn(iconProps, "text-blue-600")} />;
|
|
||||||
case code >= 500 && code < 600:
|
|
||||||
return <CloudRain className={cn(iconProps, "text-blue-600")} />;
|
|
||||||
case code >= 600 && code < 700:
|
|
||||||
return <CloudSnow className={cn(iconProps, "text-blue-400")} />;
|
|
||||||
case code >= 700 && code < 721:
|
|
||||||
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
|
|
||||||
case code === 721:
|
|
||||||
return <Haze className={cn(iconProps, "text-gray-700")} />;
|
|
||||||
case code >= 722 && code < 781:
|
|
||||||
return <CloudFog className={cn(iconProps, "text-gray-600")} />;
|
|
||||||
case code === 781:
|
|
||||||
return <Tornado className={cn(iconProps, "text-gray-700")} />;
|
|
||||||
case code === 800:
|
|
||||||
return currentTime.getHours() >= 6 && currentTime.getHours() < 18 ? (
|
|
||||||
<Sun className={cn(iconProps, "text-yellow-500")} />
|
|
||||||
) : (
|
|
||||||
<Moon className={cn(iconProps, "text-gray-300")} />
|
|
||||||
);
|
|
||||||
case code >= 800 && code < 803:
|
|
||||||
return <CloudSun className={cn(iconProps, "text-gray-600")} />;
|
|
||||||
case code >= 803:
|
|
||||||
return <Cloud className={cn(iconProps, "text-gray-600")} />;
|
|
||||||
default:
|
|
||||||
return <CircleAlert className={cn(iconProps, "text-red-500")} />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (timestamp) => {
|
|
||||||
if (!timestamp) return '--:--';
|
|
||||||
const date = new Date(timestamp * 1000);
|
|
||||||
return date.toLocaleTimeString('en-US', {
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const WeatherDetails = () => (
|
|
||||||
<div className="space-y-4 p-3">
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ThermometerSun className="w-5 h-5 text-orange-500" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs text-muted-foreground">High</span>
|
|
||||||
<span className="text-sm font-bold">{Math.round(weather.main.temp_max)}°F</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ThermometerSnowflake className="w-5 h-5 text-blue-500" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs text-muted-foreground">Low</span>
|
|
||||||
<span className="text-sm font-bold">{Math.round(weather.main.temp_min)}°F</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Droplets className="w-5 h-5 text-blue-400" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs text-muted-foreground">Humidity</span>
|
|
||||||
<span className="text-sm font-bold">{weather.main.humidity}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Wind className="w-5 h-5 text-gray-500" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs text-muted-foreground">Wind</span>
|
|
||||||
<span className="text-sm font-bold">{Math.round(weather.wind.speed)} mph</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Sunrise className="w-5 h-5 text-yellow-500" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs text-muted-foreground">Sunrise</span>
|
|
||||||
<span className="text-sm font-bold">{formatTime(weather.sys?.sunrise)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-2">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Sunset className="w-5 h-5 text-orange-400" />
|
|
||||||
<div className="flex flex-col">
|
|
||||||
<span className="text-xs text-muted-foreground">Sunset</span>
|
|
||||||
<span className="text-sm font-bold">{formatTime(weather.sys?.sunset)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{forecast && (
|
|
||||||
<div>
|
|
||||||
<div className="grid grid-cols-5 gap-2">
|
|
||||||
{forecast.map((day, index) => (
|
|
||||||
<Card key={index} className="p-2">
|
|
||||||
<div className="flex flex-col items-center gap-1">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{new Date(day.dt * 1000).toLocaleDateString('en-US', { weekday: 'short' })}
|
|
||||||
</span>
|
|
||||||
{getWeatherIcon(day.weather[0].id, new Date(day.dt * 1000), true)}
|
|
||||||
<div className="flex justify-center gap-1 items-baseline w-full">
|
|
||||||
<span className="text-sm font-bold">
|
|
||||||
{Math.round(day.main.temp_max)}°
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{Math.round(day.main.temp_min)}°
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col items-center gap-1 w-full pt-1">
|
|
||||||
{day.rain?.['3h'] > 0 && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<CloudRain className="w-3 h-3 text-blue-400" />
|
|
||||||
<span className="text-xs">{day.rain['3h'].toFixed(2)}"</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{day.snow?.['3h'] > 0 && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<CloudSnow className="w-3 h-3 text-blue-400" />
|
|
||||||
<span className="text-xs">{day.snow['3h'].toFixed(2)}"</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!day.rain?.['3h'] && !day.snow?.['3h'] && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Umbrella className="w-3 h-3 text-gray-400" />
|
|
||||||
<span className="text-xs">0"</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const formatDate = (date) =>
|
|
||||||
date.toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
year: "numeric",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
const formatTimeDisplay = (date) => {
|
|
||||||
const hours = date.getHours();
|
|
||||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
||||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
||||||
const period = hours >= 12 ? "PM" : "AM";
|
|
||||||
const displayHours = hours % 12 || 12;
|
|
||||||
return `${displayHours}:${minutes}:${seconds} ${period}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
const Header = ({ right = null }) => {
|
||||||
|
const now = useNow();
|
||||||
return (
|
return (
|
||||||
<Card
|
<header className="px-3 pt-5 sm:px-4 lg:px-5">
|
||||||
className={cn(
|
<div className="flex items-center justify-between gap-4 pb-1">
|
||||||
`w-full ${CARD_STYLES.solid} shadow-sm`,
|
<div className="min-w-0">
|
||||||
isStuck ? "rounded-b-lg border-b-1" : "border-b-0 rounded-b-none"
|
<h1 className="text-lg font-bold tracking-tight text-[#2b2925]">
|
||||||
)}
|
{greeting(now)}
|
||||||
>
|
</h1>
|
||||||
<CardContent className="p-4">
|
<p className="hidden text-xs text-[#7c7870] sm:block">
|
||||||
<div className="flex flex-col justify-between lg:flex-row items-center sm:items-center flex-wrap">
|
Here's how the shop is doing
|
||||||
<div className="flex items-center space-x-4">
|
</p>
|
||||||
<div className="flex space-x-2">
|
|
||||||
<div
|
|
||||||
onClick={toggleTheme}
|
|
||||||
className={cn(
|
|
||||||
"bg-gradient-to-r from-blue-500 to-blue-600 p-3 rounded-lg shadow-md cursor-pointer hover:opacity-90 transition-opacity",
|
|
||||||
theme === "light" && "ring-1 ring-yellow-300",
|
|
||||||
theme === "dark" && "ring-1 ring-purple-300",
|
|
||||||
"ring-offset-2 ring-offset-white dark:ring-offset-gray-900"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CraftsIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold bg-gradient-to-r from-blue-600 to-blue-400 bg-clip-text text-transparent">
|
|
||||||
ACOT Dashboard
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-left sm:items-center justify-center flex-wrap mt-2 sm:mt-0">
|
|
||||||
{weather?.main && (
|
|
||||||
<>
|
|
||||||
<div className="flex-col items-center text-center">
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<div className="items-center justify-center space-x-2 rounded-lg px-4 hidden sm:flex cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors p-2">
|
|
||||||
{getWeatherIcon(weather.weather[0]?.id, currentTime)}
|
|
||||||
<div>
|
|
||||||
<p className="text-xl font-bold tracking-tight dark:text-gray-100">
|
|
||||||
{Math.round(weather.main.temp)}° F
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{weather.alerts && (
|
|
||||||
<AlertTriangle className="w-5 h-5 text-red-500 ml-1" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[450px]" align="end" side="bottom" sideOffset={5}>
|
|
||||||
{weather.alerts && (
|
|
||||||
<Alert variant="warning" className="mb-3">
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
<AlertDescription className="text-xs">
|
|
||||||
{weather.alerts[0].event}
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
<WeatherDetails />
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
|
|
||||||
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
|
|
||||||
<Calendar className="w-5 h-5 text-green-500 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm sm:text-xl font-bold tracking-tight p-0 dark:text-gray-100">
|
|
||||||
{formatDate(currentTime)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="h-10 w-px bg-gradient-to-b from-gray-200 to-gray-200 dark:from-gray-700 dark:to-gray-700 hidden sm:block"></div>
|
|
||||||
<div className="flex items-center space-x-1 sm:space-x-3 rounded-lg px-4 py-2">
|
|
||||||
<Clock className="w-5 h-5 text-blue-500 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<p className="text-md sm:text-xl font-bold tracking-tight tabular-nums dark:text-gray-100 mr-2">
|
|
||||||
{formatTimeDisplay(currentTime)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<div className="flex shrink-0 items-center gap-3">
|
||||||
</Card>
|
{right}
|
||||||
|
<span className="text-xs text-[#7c7870]">{formatDate(now)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -49,18 +49,18 @@ const MetricCellContent = ({
|
|||||||
if (isSMS && hideForSMS) {
|
if (isSMS && hideForSMS) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-muted-foreground text-lg font-semibold">N/A</div>
|
<div className="text-[13px] font-bold text-[#d8d4cd]">N/A</div>
|
||||||
<div className="text-muted-foreground text-sm">-</div>
|
<div className="text-[10.5px] text-[#a09b92]">-</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
<div className="text-[13px] font-bold tabular-nums text-[#2b2925]">
|
||||||
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
|
{isMonetary ? formatCurrency(value) : formatRate(value, isSMS, hideForSMS)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-[10.5px] text-[#a09b92]">
|
||||||
{count?.toLocaleString() || 0} {count === 1 ? "recipient" : "recipients"}
|
{count?.toLocaleString() || 0} {count === 1 ? "recipient" : "recipients"}
|
||||||
{showConversionRate &&
|
{showConversionRate &&
|
||||||
totalRecipients > 0 &&
|
totalRecipients > 0 &&
|
||||||
@@ -245,21 +245,6 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "click_to_open_rate",
|
|
||||||
header: "CTR",
|
|
||||||
align: "center",
|
|
||||||
sortable: true,
|
|
||||||
render: (_, campaign) => (
|
|
||||||
<MetricCellContent
|
|
||||||
value={campaign.stats.click_to_open_rate}
|
|
||||||
count={campaign.stats.clicks_unique}
|
|
||||||
totalRecipients={campaign.stats.opens_unique}
|
|
||||||
isSMS={campaign.channel === 'sms'}
|
|
||||||
hideForSMS={true}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "conversion_value",
|
key: "conversion_value",
|
||||||
header: "Orders",
|
header: "Orders",
|
||||||
@@ -284,11 +269,11 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="Klaviyo Campaigns"
|
title="Klaviyo Campaigns"
|
||||||
loading={true}
|
loading={true}
|
||||||
compact
|
size="large"
|
||||||
actions={<div className="w-[200px]" />}
|
actions={<div className="w-[200px]" />}
|
||||||
timeSelector={<div className="w-[130px]" />}
|
timeSelector={<div className="w-[130px]" />}
|
||||||
/>
|
/>
|
||||||
<CardContent className="overflow-y-auto pl-4 max-h-[400px] mb-4">
|
<CardContent className="overflow-y-auto p-4 pt-3 max-h-[400px]">
|
||||||
<TableSkeleton rows={15} columns={6} variant="detailed" />
|
<TableSkeleton rows={15} columns={6} variant="detailed" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -301,60 +286,58 @@ const KlaviyoCampaigns = ({ className }) => {
|
|||||||
<DashboardErrorState
|
<DashboardErrorState
|
||||||
title="Failed to load campaigns"
|
title="Failed to load campaigns"
|
||||||
message={error}
|
message={error}
|
||||||
className="mx-6 mt-4"
|
className="mx-4 mt-3"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="Klaviyo Campaigns"
|
title="Klaviyo Campaigns"
|
||||||
compact
|
size="large"
|
||||||
actions={
|
actions={
|
||||||
<div className="flex gap-1 items-center">
|
<div className="flex items-center gap-0.5">
|
||||||
<Button
|
{/* When all channels are on, "active" reads as the default state so
|
||||||
variant={selectedChannels.email ? "default" : "outline"}
|
all three highlight; clicking one isolates it, clicking again
|
||||||
size="sm"
|
resets to all. Only the isolated channel highlights then. */}
|
||||||
onClick={() => setSelectedChannels(prev => {
|
{(() => {
|
||||||
if (prev.email && Object.values(prev).filter(Boolean).length === 1) {
|
const allOn = selectedChannels.email && selectedChannels.sms && selectedChannels.blog;
|
||||||
return { email: true, sms: true, blog: true };
|
const toggle = (channel) =>
|
||||||
}
|
setSelectedChannels((prev) => {
|
||||||
return { email: true, sms: false, blog: false };
|
const onlyThis = prev[channel] && Object.values(prev).filter(Boolean).length === 1;
|
||||||
})}
|
if (onlyThis) return { email: true, sms: true, blog: true };
|
||||||
>
|
return {
|
||||||
<Mail className="h-4 w-4" />
|
email: channel === "email",
|
||||||
<span className="hidden sm:inline">Email</span>
|
sms: channel === "sms",
|
||||||
</Button>
|
blog: channel === "blog",
|
||||||
<Button
|
};
|
||||||
variant={selectedChannels.sms ? "default" : "outline"}
|
});
|
||||||
size="sm"
|
return [
|
||||||
onClick={() => setSelectedChannels(prev => {
|
{ key: "email", label: "Email" },
|
||||||
if (prev.sms && Object.values(prev).filter(Boolean).length === 1) {
|
{ key: "sms", label: "SMS" },
|
||||||
return { email: true, sms: true, blog: true };
|
{ key: "blog", label: "Blog" },
|
||||||
}
|
].map(({ key, label }) => {
|
||||||
return { email: false, sms: true, blog: false };
|
const active = !allOn && selectedChannels[key];
|
||||||
})}
|
return (
|
||||||
>
|
<button
|
||||||
<MessageSquare className="h-4 w-4" />
|
key={key}
|
||||||
<span className="hidden sm:inline">SMS</span>
|
type="button"
|
||||||
</Button>
|
onClick={() => toggle(key)}
|
||||||
<Button
|
className={`h-7 shrink-0 rounded-full px-2.5 text-xs font-medium transition-colors ${
|
||||||
variant={selectedChannels.blog ? "default" : "outline"}
|
active
|
||||||
size="sm"
|
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
|
||||||
onClick={() => setSelectedChannels(prev => {
|
: "text-[#a09b92] hover:bg-[#f5f3f0] hover:text-[#2b2925]"
|
||||||
if (prev.blog && Object.values(prev).filter(Boolean).length === 1) {
|
}`}
|
||||||
return { email: true, sms: true, blog: true };
|
>
|
||||||
}
|
{label}
|
||||||
return { email: false, sms: false, blog: true };
|
</button>
|
||||||
})}
|
);
|
||||||
>
|
});
|
||||||
<BookOpen className="h-4 w-4" />
|
})()}
|
||||||
<span className="hidden sm:inline">Blog</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
timeSelector={
|
timeSelector={
|
||||||
<BusinessRangeSelect value={selectedTimeRange} onValueChange={setSelectedTimeRange} />
|
<BusinessRangeSelect value={selectedTimeRange} onValueChange={setSelectedTimeRange} />
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CardContent className="pl-4 mb-4">
|
<CardContent className="p-4 pt-3">
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={filteredCampaigns}
|
data={filteredCampaigns}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { apiFetch } from "@/utils/api";
|
import { apiFetch } from "@/utils/api";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -28,6 +28,9 @@ import {
|
|||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
DashboardTable,
|
DashboardTable,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
|
QuietStat,
|
||||||
|
QUIET_STRIP_CLASS,
|
||||||
|
PILL_TRIGGER_CLASS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
// Helper functions for formatting
|
// Helper functions for formatting
|
||||||
@@ -59,11 +62,11 @@ const MetricCellContent = ({ value, label, sublabel, isMonetary = false, isPerce
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-blue-600 dark:text-blue-400 text-lg font-semibold">
|
<div className="text-[13px] font-bold tabular-nums text-[#2b2925]">
|
||||||
{formattedValue}
|
{formattedValue}
|
||||||
</div>
|
</div>
|
||||||
{(label || sublabel) && (
|
{(label || sublabel) && (
|
||||||
<div className="text-muted-foreground text-sm">
|
<div className="text-[10.5px] text-[#a09b92]">
|
||||||
{label || sublabel}
|
{label || sublabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -385,28 +388,6 @@ const MetaCampaigns = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "impressions",
|
|
||||||
header: "Impressions",
|
|
||||||
align: "center",
|
|
||||||
sortable: true,
|
|
||||||
render: (_, campaign) => (
|
|
||||||
<MetricCellContent value={campaign.metrics.impressions} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cpm",
|
|
||||||
header: "CPM",
|
|
||||||
align: "center",
|
|
||||||
sortable: true,
|
|
||||||
render: (_, campaign) => (
|
|
||||||
<MetricCellContent
|
|
||||||
value={campaign.metrics.cpm}
|
|
||||||
isMonetary
|
|
||||||
decimalPlaces={2}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "ctr",
|
key: "ctr",
|
||||||
header: "CTR",
|
header: "CTR",
|
||||||
@@ -447,15 +428,6 @@ const MetaCampaigns = () => {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "engagements",
|
|
||||||
header: "Engagements",
|
|
||||||
align: "center",
|
|
||||||
sortable: true,
|
|
||||||
render: (_, campaign) => (
|
|
||||||
<MetricCellContent value={campaign.metrics.totalPostEngagements} />
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -464,17 +436,15 @@ const MetaCampaigns = () => {
|
|||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="Meta Ads Performance"
|
title="Meta Ads Performance"
|
||||||
loading={true}
|
loading={true}
|
||||||
compact
|
size="large"
|
||||||
timeSelector={<div className="w-[130px]" />}
|
timeSelector={<div className="w-[130px]" />}
|
||||||
/>
|
/>
|
||||||
<CardHeader className="pt-0 pb-2">
|
<CardContent className="p-4 pt-3 space-y-3">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-2">
|
||||||
{[...Array(12)].map((_, i) => (
|
{[...Array(12)].map((_, i) => (
|
||||||
<DashboardStatCardSkeleton key={i} size="compact" />
|
<DashboardStatCardSkeleton key={i} size="compact" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<TableSkeleton rows={5} columns={9} variant="detailed" />
|
<TableSkeleton rows={5} columns={9} variant="detailed" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -498,10 +468,10 @@ const MetaCampaigns = () => {
|
|||||||
<Card className={`h-full ${CARD_STYLES.base}`}>
|
<Card className={`h-full ${CARD_STYLES.base}`}>
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="Meta Ads Performance"
|
title="Meta Ads Performance"
|
||||||
compact
|
size="large"
|
||||||
timeSelector={
|
timeSelector={
|
||||||
<Select value={timeframe} onValueChange={setTimeframe}>
|
<Select value={timeframe} onValueChange={setTimeframe}>
|
||||||
<SelectTrigger className="w-[130px] bg-background">
|
<SelectTrigger className={PILL_TRIGGER_CLASS}>
|
||||||
<SelectValue placeholder="Select range" />
|
<SelectValue placeholder="Select range" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -514,96 +484,24 @@ const MetaCampaigns = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CardHeader className="pt-0 pb-2">
|
<CardContent className="p-4 pt-3 space-y-3">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4 dashboard-stagger">
|
{/* Summary metrics as a quiet strip — same deemphasized treatment as
|
||||||
<DashboardStatCard
|
the secondary stats on the Overview section */}
|
||||||
title="Active Campaigns"
|
<div className={`${QUIET_STRIP_CLASS} grid-cols-2 sm:grid-cols-3 lg:grid-cols-6`}>
|
||||||
value={formatNumber(summaryMetrics?.totalCampaigns)}
|
<QuietStat label="Campaigns" value={formatNumber(summaryMetrics?.totalCampaigns)} />
|
||||||
icon={Target}
|
<QuietStat label="Spend" value={formatCurrency(summaryMetrics?.totalSpend, 0)} />
|
||||||
iconColor="purple"
|
<QuietStat label="Reach" value={formatNumber(summaryMetrics?.totalReach)} />
|
||||||
size="compact"
|
<QuietStat label="Impressions" value={formatNumber(summaryMetrics?.totalImpressions)} />
|
||||||
/>
|
<QuietStat label="Frequency" value={formatNumber(summaryMetrics?.avgFrequency, 2)} />
|
||||||
<DashboardStatCard
|
<QuietStat label="Engagements" value={formatNumber(summaryMetrics?.totalPostEngagements)} />
|
||||||
title="Total Spend"
|
<QuietStat label="CPM" value={formatCurrency(summaryMetrics?.avgCpm, 2)} />
|
||||||
value={formatCurrency(summaryMetrics?.totalSpend, 0)}
|
<QuietStat label="CTR" value={formatPercent(summaryMetrics?.avgCtr, 2)} />
|
||||||
icon={DollarSign}
|
<QuietStat label="CPC" value={formatCurrency(summaryMetrics?.avgCpc, 2)} />
|
||||||
iconColor="green"
|
<QuietStat label="Link clicks" value={formatNumber(summaryMetrics?.totalLinkClicks)} />
|
||||||
size="compact"
|
<QuietStat label="Purchases" value={formatNumber(summaryMetrics?.totalPurchases)} />
|
||||||
/>
|
<QuietStat label="Purchase value" value={formatCurrency(summaryMetrics?.totalPurchaseValue, 0)} />
|
||||||
<DashboardStatCard
|
|
||||||
title="Total Reach"
|
|
||||||
value={formatNumber(summaryMetrics?.totalReach)}
|
|
||||||
icon={Users}
|
|
||||||
iconColor="blue"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Total Impressions"
|
|
||||||
value={formatNumber(summaryMetrics?.totalImpressions)}
|
|
||||||
icon={Eye}
|
|
||||||
iconColor="indigo"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Avg Frequency"
|
|
||||||
value={formatNumber(summaryMetrics?.avgFrequency, 2)}
|
|
||||||
icon={Repeat}
|
|
||||||
iconColor="cyan"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Total Engagements"
|
|
||||||
value={formatNumber(summaryMetrics?.totalPostEngagements)}
|
|
||||||
icon={MessageCircle}
|
|
||||||
iconColor="pink"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Avg CPM"
|
|
||||||
value={formatCurrency(summaryMetrics?.avgCpm, 2)}
|
|
||||||
icon={DollarSign}
|
|
||||||
iconColor="emerald"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Avg CTR"
|
|
||||||
value={formatPercent(summaryMetrics?.avgCtr, 2)}
|
|
||||||
icon={BarChart}
|
|
||||||
iconColor="orange"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Avg CPC"
|
|
||||||
value={formatCurrency(summaryMetrics?.avgCpc, 2)}
|
|
||||||
icon={MousePointer}
|
|
||||||
iconColor="rose"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Total Link Clicks"
|
|
||||||
value={formatNumber(summaryMetrics?.totalLinkClicks)}
|
|
||||||
icon={MousePointer}
|
|
||||||
iconColor="amber"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Total Purchases"
|
|
||||||
value={formatNumber(summaryMetrics?.totalPurchases)}
|
|
||||||
icon={ShoppingCart}
|
|
||||||
iconColor="teal"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Purchase Value"
|
|
||||||
value={formatCurrency(summaryMetrics?.totalPurchaseValue, 0)}
|
|
||||||
icon={DollarSign}
|
|
||||||
iconColor="lime"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="pl-4 mb-4">
|
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={sortedCampaigns}
|
data={sortedCampaigns}
|
||||||
|
|||||||
@@ -1,282 +1,127 @@
|
|||||||
import React, { useState, useEffect, useRef, useContext, useMemo } from "react";
|
import React, { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { ArrowUp } from "lucide-react";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useScroll } from "@/contexts/DashboardScrollContext";
|
|
||||||
import { ArrowUpToLine } from "lucide-react";
|
|
||||||
import { AuthContext } from "@/contexts/AuthContext";
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
|
import { useScroll } from "@/contexts/DashboardScrollContext";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
const ALL_SECTIONS = [
|
||||||
|
{ id: "stats", label: "Overview", permission: "dashboard:stats" },
|
||||||
|
{ id: "financial", label: "Financial", permission: "dashboard:financial" },
|
||||||
|
{ id: "sales", label: "Sales", permission: "dashboard:sales" },
|
||||||
|
{ id: "feed", label: "Events", permission: "dashboard:feed" },
|
||||||
|
{ id: "operations-metrics", label: "Operations", permission: "dashboard:operations" },
|
||||||
|
{ id: "payroll-metrics", label: "Payroll", permission: "dashboard:payroll" },
|
||||||
|
{ id: "products", label: "Products", permission: "dashboard:products" },
|
||||||
|
{ id: "campaigns", label: "Klaviyo", permission: "dashboard:campaigns" },
|
||||||
|
{ id: "analytics", label: "Analytics", permission: "dashboard:analytics" },
|
||||||
|
{ 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 = () => {
|
const Navigation = () => {
|
||||||
const [activeSections, setActiveSections] = useState([]);
|
const [activeSection, setActiveSection] = useState(null);
|
||||||
const { isStuck, scrollContainerRef, scrollToSection } = useScroll();
|
const { isStuck, scrollContainerRef, scrollToSection } = useScroll();
|
||||||
const { user } = useContext(AuthContext);
|
const { user } = useContext(AuthContext);
|
||||||
|
const navRef = useRef(null);
|
||||||
const buttonRefs = useRef({});
|
const buttonRefs = useRef({});
|
||||||
const scrollContainerRef2 = useRef(null);
|
|
||||||
const [shouldAutoScroll, setShouldAutoScroll] = useState(true);
|
|
||||||
const lastScrollLeft = useRef(0);
|
|
||||||
const lastScrollTop = useRef(0);
|
|
||||||
|
|
||||||
// Define all possible sections with their permission requirements
|
const sections = useMemo(() => {
|
||||||
const allSections = [
|
|
||||||
{ id: "stats", label: "Statistics", permission: "dashboard:stats" },
|
|
||||||
{ id: "realtime", label: "Realtime", permission: "dashboard:realtime" },
|
|
||||||
{ id: "financial", label: "Financial", permission: "dashboard:financial" },
|
|
||||||
{ id: "payroll-metrics", label: "Payroll", permission: "dashboard:payroll" },
|
|
||||||
{ id: "operations-metrics", label: "Operations", permission: "dashboard:operations" },
|
|
||||||
{ id: "feed", label: "Event Feed", permission: "dashboard:feed" },
|
|
||||||
{ id: "sales", label: "Sales Chart", permission: "dashboard:sales" },
|
|
||||||
{ id: "products", label: "Top Products", permission: "dashboard:products" },
|
|
||||||
{ id: "campaigns", label: "Campaigns", permission: "dashboard:campaigns" },
|
|
||||||
{ id: "analytics", label: "Analytics", permission: "dashboard:analytics" },
|
|
||||||
{ id: "user-behavior", label: "User Behavior", permission: "dashboard:user_behavior" },
|
|
||||||
{ id: "meta-campaigns", label: "Meta Ads", permission: "dashboard:meta_campaigns" },
|
|
||||||
{ id: "typeform", label: "Customer Surveys", permission: "dashboard:typeform" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Filter sections based on user permissions
|
|
||||||
const baseSections = useMemo(() => {
|
|
||||||
if (!user) return [];
|
if (!user) return [];
|
||||||
|
if (user.is_admin) return ALL_SECTIONS;
|
||||||
// Admins see all sections
|
return ALL_SECTIONS.filter((section) =>
|
||||||
if (user.is_admin) return allSections;
|
user.permissions?.includes(section.permission)
|
||||||
|
|
||||||
// Filter sections based on user permissions
|
|
||||||
return allSections.filter(section =>
|
|
||||||
user.permissions && user.permissions.includes(section.permission)
|
|
||||||
);
|
);
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const sortSections = (sections) => {
|
useEffect(() => {
|
||||||
const isMediumScreen = window.matchMedia(
|
const container = scrollContainerRef.current;
|
||||||
"(min-width: 768px) and (max-width: 1023px)"
|
if (!container || !sections.length) return undefined;
|
||||||
).matches;
|
|
||||||
|
|
||||||
return [...sections].sort((a, b) => {
|
const updateActiveSection = () => {
|
||||||
const aOrder = a.order
|
const containerTop = container.getBoundingClientRect().top;
|
||||||
? isMediumScreen
|
let current = sections[0]?.id ?? null;
|
||||||
? a.order.md
|
|
||||||
: a.order.default
|
|
||||||
: 0;
|
|
||||||
const bOrder = b.order
|
|
||||||
? isMediumScreen
|
|
||||||
? b.order.md
|
|
||||||
: b.order.default
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
if (aOrder && bOrder) {
|
sections.forEach(({ id }) => {
|
||||||
return aOrder - bOrder;
|
const element = document.getElementById(id);
|
||||||
}
|
if (element && element.getBoundingClientRect().top - containerTop <= 140) {
|
||||||
return 0;
|
current = id;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setActiveSection(current);
|
||||||
|
};
|
||||||
|
|
||||||
|
container.addEventListener("scroll", updateActiveSection, { passive: true });
|
||||||
|
updateActiveSection();
|
||||||
|
return () => container.removeEventListener("scroll", updateActiveSection);
|
||||||
|
}, [scrollContainerRef, sections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const activeButton = activeSection && buttonRefs.current[activeSection];
|
||||||
|
activeButton?.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "nearest",
|
||||||
|
inline: "center",
|
||||||
});
|
});
|
||||||
};
|
}, [activeSection]);
|
||||||
|
|
||||||
const sections = sortSections(baseSections);
|
|
||||||
|
|
||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
if (scrollContainerRef.current) {
|
scrollContainerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
|
||||||
scrollContainerRef.current.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
window.scrollTo({
|
|
||||||
top: 0,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSectionClick = (sectionId, responsiveIds) => {
|
|
||||||
scrollToSection(sectionId);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track horizontal scroll position changes
|
|
||||||
useEffect(() => {
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (!container) return;
|
|
||||||
|
|
||||||
const handleButtonBarScroll = () => {
|
|
||||||
if (Math.abs(container.scrollLeft - lastScrollLeft.current) > 5) {
|
|
||||||
setShouldAutoScroll(false);
|
|
||||||
}
|
|
||||||
lastScrollLeft.current = container.scrollLeft;
|
|
||||||
};
|
|
||||||
|
|
||||||
container.addEventListener("scroll", handleButtonBarScroll);
|
|
||||||
return () => container.removeEventListener("scroll", handleButtonBarScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle page scroll and active sections
|
|
||||||
useEffect(() => {
|
|
||||||
const handlePageScroll = (e) => {
|
|
||||||
const scrollTop = e?.target?.scrollTop || window.pageYOffset || document.documentElement.scrollTop;
|
|
||||||
|
|
||||||
if (Math.abs(scrollTop - lastScrollTop.current) > 5) {
|
|
||||||
setShouldAutoScroll(true);
|
|
||||||
lastScrollTop.current = scrollTop;
|
|
||||||
} else {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeIds = [];
|
|
||||||
const viewportHeight = window.innerHeight;
|
|
||||||
const threshold = viewportHeight * 0.5;
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
|
|
||||||
sections.forEach((section) => {
|
|
||||||
if (section.responsiveIds) {
|
|
||||||
const visibleId = section.responsiveIds.find((id) => {
|
|
||||||
const element = document.getElementById(id);
|
|
||||||
if (!element) return false;
|
|
||||||
const style = window.getComputedStyle(element);
|
|
||||||
if (style.display === "none") return false;
|
|
||||||
|
|
||||||
if (container) {
|
|
||||||
// For container-based scrolling
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const relativeTop = rect.top - containerRect.top;
|
|
||||||
const relativeBottom = rect.bottom - containerRect.top;
|
|
||||||
return (
|
|
||||||
relativeTop < containerRect.height - threshold &&
|
|
||||||
relativeBottom > threshold
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// For window-based scrolling
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
return (
|
|
||||||
rect.top < viewportHeight - threshold && rect.bottom > threshold
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (visibleId) {
|
|
||||||
activeIds.push(section.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const element = document.getElementById(section.id);
|
|
||||||
if (element) {
|
|
||||||
if (container) {
|
|
||||||
// For container-based scrolling
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
const containerRect = container.getBoundingClientRect();
|
|
||||||
const relativeTop = rect.top - containerRect.top;
|
|
||||||
const relativeBottom = rect.bottom - containerRect.top;
|
|
||||||
if (
|
|
||||||
relativeTop < containerRect.height - threshold &&
|
|
||||||
relativeBottom > threshold
|
|
||||||
) {
|
|
||||||
activeIds.push(section.id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// For window-based scrolling
|
|
||||||
const rect = element.getBoundingClientRect();
|
|
||||||
if (
|
|
||||||
rect.top < viewportHeight - threshold &&
|
|
||||||
rect.bottom > threshold
|
|
||||||
) {
|
|
||||||
activeIds.push(section.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
setActiveSections(activeIds);
|
|
||||||
|
|
||||||
if (shouldAutoScroll && activeIds.length > 0) {
|
|
||||||
const firstActiveButton = buttonRefs.current[activeIds[0]];
|
|
||||||
if (firstActiveButton && scrollContainerRef2.current) {
|
|
||||||
scrollContainerRef2.current.scrollTo({
|
|
||||||
left:
|
|
||||||
firstActiveButton.offsetLeft -
|
|
||||||
scrollContainerRef2.current.offsetWidth / 2 +
|
|
||||||
firstActiveButton.offsetWidth / 2,
|
|
||||||
behavior: "auto",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attach to container or window
|
|
||||||
const container = scrollContainerRef.current;
|
|
||||||
if (container) {
|
|
||||||
container.addEventListener("scroll", handlePageScroll);
|
|
||||||
handlePageScroll({ target: container });
|
|
||||||
} else {
|
|
||||||
window.addEventListener("scroll", handlePageScroll);
|
|
||||||
handlePageScroll();
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (container) {
|
|
||||||
container.removeEventListener("scroll", handlePageScroll);
|
|
||||||
} else {
|
|
||||||
window.removeEventListener("scroll", handlePageScroll);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [sections, shouldAutoScroll, scrollContainerRef]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"sticky z-50 px-4 transition-all duration-200",
|
"sticky top-0 z-40 px-3 sm:px-4 lg:px-5",
|
||||||
isStuck ? "top-1 sm:top-2 md:top-4 rounded-lg" : "rounded-t-none"
|
isStuck && "bg-[#f8f7f5]/90 backdrop-blur-lg"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Card
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full bg-background transition-all duration-200",
|
"flex min-w-0 items-center border-b border-[#eceae5] transition-shadow",
|
||||||
isStuck
|
isStuck && "shadow-[0_8px_16px_-16px_rgba(43,41,37,0.35)]"
|
||||||
? "rounded-lg mt-2 shadow-md"
|
|
||||||
: "shadow-sm rounded-t-none border-t-0 -mt-6 pb-2"
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="py-2 px-4">
|
<div
|
||||||
<div className="grid grid-cols-[1fr_auto] items-center min-w-0 relative">
|
ref={navRef}
|
||||||
<div
|
className="no-scrollbar flex min-w-0 flex-1 gap-0.5 overflow-x-auto py-2"
|
||||||
ref={scrollContainerRef2}
|
>
|
||||||
className="overflow-x-auto no-scrollbar min-w-0 -mx-1 px-1 touch-pan-x overscroll-y-contain pr-12"
|
{sections.map(({ id, label }) => (
|
||||||
|
<Button
|
||||||
|
key={id}
|
||||||
|
ref={(element) => {
|
||||||
|
buttonRefs.current[id] = element;
|
||||||
|
}}
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className={cn(
|
||||||
|
"h-7 shrink-0 rounded-full px-3 text-xs font-medium text-[#7c7870]",
|
||||||
|
"hover:bg-[#f0eeea] hover:text-[#2b2925]",
|
||||||
|
activeSection === id &&
|
||||||
|
"bg-[#2b2925] text-white hover:bg-[#2b2925] hover:text-white"
|
||||||
|
)}
|
||||||
|
onClick={() => scrollToSection(id)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-nowrap space-x-1">
|
{label}
|
||||||
{sections.map(({ id, label, responsiveIds }) => (
|
</Button>
|
||||||
<Button
|
))}
|
||||||
key={id}
|
</div>
|
||||||
ref={(el) => (buttonRefs.current[id] = el)}
|
|
||||||
variant={activeSections.includes(id) ? "default" : "ghost"}
|
{isStuck && (
|
||||||
size="sm"
|
<Button
|
||||||
className={cn(
|
variant="ghost"
|
||||||
"whitespace-nowrap flex-shrink-0 px-1 md:px-3 py-2 transition-all duration-200",
|
size="icon"
|
||||||
activeSections.includes(id) &&
|
className="ml-2 h-7 w-7 shrink-0"
|
||||||
"bg-blue-100 dark:bg-blue-900/70 text-primary dark:text-blue-100 shadow-sm hover:bg-blue-100 dark:hover:bg-blue-900/70 md:hover:bg-blue-200 dark:md:hover:bg-blue-900",
|
onClick={scrollToTop}
|
||||||
!activeSections.includes(id) &&
|
aria-label="Scroll to top"
|
||||||
"hover:bg-blue-100 dark:hover:bg-blue-900/40 md:hover:bg-blue-50 dark:md:hover:bg-blue-900/20 hover:text-primary dark:hover:text-blue-100 dark:text-gray-400",
|
>
|
||||||
"focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring focus-visible:ring-offset-background",
|
<ArrowUp className="h-3.5 w-3.5" />
|
||||||
"disabled:pointer-events-none disabled:opacity-50"
|
</Button>
|
||||||
)}
|
)}
|
||||||
onClick={() => handleSectionClick(id, responsiveIds)}
|
</div>
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="absolute -right-2.5 top-0 bottom-0 flex items-center bg-background pl-1 pr-0">
|
|
||||||
<Button
|
|
||||||
variant="icon"
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
"flex-shrink-0 h-10 w-10 p-0 hover:bg-blue-100 dark:hover:bg-blue-900/40",
|
|
||||||
isStuck ? "" : "hidden"
|
|
||||||
)}
|
|
||||||
onClick={scrollToTop}
|
|
||||||
>
|
|
||||||
<ArrowUpToLine className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { acotService } from "@/services/dashboard/acotService";
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -22,7 +20,6 @@ import {
|
|||||||
Area,
|
Area,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
Legend,
|
|
||||||
Line,
|
Line,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -35,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,
|
||||||
@@ -44,7 +41,8 @@ import {
|
|||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
METRIC_COLORS,
|
MetricPill,
|
||||||
|
PILL_TRIGGER_CLASS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip as TooltipUI, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { toDateOnly } from "@/utils/businessTime";
|
import { toDateOnly } from "@/utils/businessTime";
|
||||||
@@ -127,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> = {
|
||||||
@@ -598,7 +598,7 @@ const OperationsMetrics = () => {
|
|||||||
actions={headerActions}
|
actions={headerActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0">
|
<CardContent className="p-4 pt-3 space-y-3">
|
||||||
{!error && (
|
{!error && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<SkeletonStats />
|
<SkeletonStats />
|
||||||
@@ -608,43 +608,35 @@ const OperationsMetrics = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!error && (
|
{!error && (
|
||||||
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
<div className="flex flex-wrap gap-1">
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
{SERIES_DEFINITIONS.map((series) => (
|
<MetricPill
|
||||||
<Button
|
key={series.key}
|
||||||
key={series.key}
|
active={metrics[series.key]}
|
||||||
variant={metrics[series.key] ? "default" : "outline"}
|
color={chartColors[series.key]}
|
||||||
size="sm"
|
onClick={() => toggleMetric(series.key)}
|
||||||
onClick={() => toggleMetric(series.key)}
|
>
|
||||||
>
|
{series.label}
|
||||||
{series.label}
|
</MetricPill>
|
||||||
</Button>
|
))}
|
||||||
))}
|
<span className="mx-1.5 h-4 w-px bg-[#eceae5]" />
|
||||||
</div>
|
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||||||
|
<SelectTrigger className={PILL_TRIGGER_CLASS}>
|
||||||
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
<SelectValue placeholder="Group by" />
|
||||||
<Separator orientation="horizontal" className="sm:hidden w-20 my-2" />
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-xl">
|
||||||
<div className="flex items-center gap-2">
|
{GROUP_BY_CHOICES.map((option) => (
|
||||||
<div className="text-sm text-muted-foreground">Group:</div>
|
<SelectItem key={option.value} value={option.value}>
|
||||||
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
{option.label}
|
||||||
<SelectTrigger className="w-[100px]">
|
</SelectItem>
|
||||||
<SelectValue placeholder="Group By" />
|
))}
|
||||||
</SelectTrigger>
|
</SelectContent>
|
||||||
<SelectContent>
|
</Select>
|
||||||
{GROUP_BY_CHOICES.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col lg:flex-row gap-6 mt-4">
|
<div className="flex flex-col lg:flex-row gap-3">
|
||||||
<div className="w-full lg:w-[45%]">
|
<div className="w-full lg:w-[45%]">
|
||||||
<LeaderboardTableSkeleton />
|
<LeaderboardTableSkeleton />
|
||||||
</div>
|
</div>
|
||||||
@@ -661,14 +653,14 @@ const OperationsMetrics = () => {
|
|||||||
description="Try selecting a different time range"
|
description="Try selecting a different time range"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col lg:flex-row gap-6 mt-4">
|
<div className="flex flex-col lg:flex-row gap-3">
|
||||||
<div className="w-full lg:w-[45%]">
|
<div className="w-full lg:w-[45%]">
|
||||||
<OperationsLeaderboard
|
<OperationsLeaderboard
|
||||||
picking={data?.byEmployee?.picking ?? []}
|
picking={data?.byEmployee?.picking ?? []}
|
||||||
shipping={data?.byEmployee?.shipping ?? []}
|
shipping={data?.byEmployee?.shipping ?? []}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={`h-[300px] w-full lg:w-[55%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
<div className={`h-[280px] w-full lg:w-[55%] ${CARD_STYLES.subtle} p-0 relative`}>
|
||||||
{!hasActiveMetrics ? (
|
{!hasActiveMetrics ? (
|
||||||
<DashboardEmptyState
|
<DashboardEmptyState
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
@@ -677,7 +669,7 @@ const OperationsMetrics = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 5, right: 15, left: 15, bottom: 5 }}>
|
<ComposedChart data={chartData} margin={{ top: 8, right: 0, left: -8, bottom: 0 }}>
|
||||||
<defs>
|
<defs>
|
||||||
<linearGradient id="operationsOrdersPicked" x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id="operationsOrdersPicked" x1="0" y1="0" x2="0" y2="1">
|
||||||
<stop offset="5%" stopColor={chartColors.ordersPicked} stopOpacity={0.8} />
|
<stop offset="5%" stopColor={chartColors.ordersPicked} stopOpacity={0.8} />
|
||||||
@@ -706,7 +698,6 @@ const OperationsMetrics = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={<OperationsTooltip />} />
|
<Tooltip content={<OperationsTooltip />} />
|
||||||
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
|
|
||||||
|
|
||||||
{metrics.ordersPicked && (
|
{metrics.ordersPicked && (
|
||||||
<Area
|
<Area
|
||||||
@@ -791,7 +782,7 @@ const ICON_MAP = {
|
|||||||
|
|
||||||
function OperationsStatGrid({ cards }: { cards: OperationsStatCardConfig[] }) {
|
function OperationsStatGrid({ cards }: { cards: OperationsStatCardConfig[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
@@ -817,7 +808,7 @@ function OperationsStatGrid({ cards }: { cards: OperationsStatCardConfig[] }) {
|
|||||||
|
|
||||||
function SkeletonStats() {
|
function SkeletonStats() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
||||||
))}
|
))}
|
||||||
@@ -868,8 +859,8 @@ const OperationsTooltip = ({ active, payload, label }: TooltipProps<number, stri
|
|||||||
|
|
||||||
function LeaderboardTableSkeleton() {
|
function LeaderboardTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className={`h-[280px] rounded-lg border ${CARD_STYLES.base} overflow-hidden`}>
|
<div className="h-[280px] overflow-hidden rounded-xl border border-[#efede9] bg-[#faf9f7]">
|
||||||
<div className="p-3 border-b bg-muted/30">
|
<div className="p-3 border-b border-[#f0eeea]">
|
||||||
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 space-y-2">
|
<div className="p-2 space-y-2">
|
||||||
@@ -954,26 +945,23 @@ function OperationsLeaderboard({
|
|||||||
|
|
||||||
if (leaderboard.length === 0) {
|
if (leaderboard.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} flex items-center justify-center`}>
|
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] flex items-center justify-center">
|
||||||
<p className="text-sm text-muted-foreground">No employee data</p>
|
<p className="text-sm text-muted-foreground">No employee data</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden flex flex-col`}>
|
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] overflow-hidden flex flex-col">
|
||||||
<div className="p-3 border-b bg-muted/30 flex-none">
|
|
||||||
<h4 className="text-sm font-medium">Top Performers</h4>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="sticky top-0 z-10 bg-[#faf9f7]">
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-8 text-xs px-2 w-8" />
|
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] w-8" />
|
||||||
<TableHead className="h-8 text-xs px-2">Name</TableHead>
|
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Name</TableHead>
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">Picked</TableHead>
|
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] text-right">Picked</TableHead>
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">Shipped</TableHead>
|
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] text-right">Shipped</TableHead>
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">
|
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] text-right">
|
||||||
<TooltipUI>
|
<TooltipUI>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="cursor-help">Hours</span>
|
<span className="cursor-help">Hours</span>
|
||||||
@@ -983,7 +971,7 @@ function OperationsLeaderboard({
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</TooltipUI>
|
</TooltipUI>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">Speed</TableHead>
|
<TableHead className="h-8 px-2 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92] text-right">Speed</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Legend,
|
|
||||||
Line,
|
Line,
|
||||||
ComposedChart,
|
ComposedChart,
|
||||||
|
Rectangle,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
@@ -30,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,
|
||||||
@@ -39,7 +39,8 @@ import {
|
|||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
METRIC_COLORS,
|
LegendChip,
|
||||||
|
PILL_TRIGGER_CLASS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
type ComparisonValue = {
|
type ComparisonValue = {
|
||||||
@@ -123,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", {
|
||||||
@@ -454,7 +466,7 @@ const PayrollMetrics = () => {
|
|||||||
}}
|
}}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 w-[110px]">
|
<SelectTrigger className={PILL_TRIGGER_CLASS}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -470,7 +482,7 @@ const PayrollMetrics = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9"
|
className="h-8 w-8 rounded-full border-[#e7e5e1] shadow-none text-[#7c7870] hover:bg-[#faf9f7] hover:text-[#2b2925]"
|
||||||
onClick={() => navigatePeriod("prev")}
|
onClick={() => navigatePeriod("prev")}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
@@ -478,7 +490,7 @@ const PayrollMetrics = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-9 px-3 min-w-[120px]"
|
className="h-8 min-w-[110px] rounded-full border-[#e7e5e1] px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
|
||||||
onClick={goToCurrentPeriod}
|
onClick={goToCurrentPeriod}
|
||||||
disabled={loading || isAtCurrentPeriod}
|
disabled={loading || isAtCurrentPeriod}
|
||||||
>
|
>
|
||||||
@@ -488,7 +500,7 @@ const PayrollMetrics = () => {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9"
|
className="h-8 w-8 rounded-full border-[#e7e5e1] shadow-none text-[#7c7870] hover:bg-[#faf9f7] hover:text-[#2b2925]"
|
||||||
onClick={() => navigatePeriod("next")}
|
onClick={() => navigatePeriod("next")}
|
||||||
disabled={loading || isAtCurrentPeriod}
|
disabled={loading || isAtCurrentPeriod}
|
||||||
>
|
>
|
||||||
@@ -506,7 +518,7 @@ const PayrollMetrics = () => {
|
|||||||
actions={headerActions}
|
actions={headerActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0">
|
<CardContent className="p-4 pt-3 space-y-3">
|
||||||
{!error && (
|
{!error && (
|
||||||
loading ? (
|
loading ? (
|
||||||
<SkeletonStats />
|
<SkeletonStats />
|
||||||
@@ -516,7 +528,7 @@ const PayrollMetrics = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex flex-col lg:flex-row gap-6">
|
<div className="flex flex-col lg:flex-row gap-3">
|
||||||
<div className="w-full lg:w-[65%]">
|
<div className="w-full lg:w-[65%]">
|
||||||
<ChartSkeleton type="bar" height="md" withCard={false} />
|
<ChartSkeleton type="bar" height="md" withCard={false} />
|
||||||
</div>
|
</div>
|
||||||
@@ -533,10 +545,18 @@ const PayrollMetrics = () => {
|
|||||||
description="Try selecting a different pay period"
|
description="Try selecting a different pay period"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col lg:flex-row gap-6">
|
<div className="flex flex-col lg:flex-row gap-3">
|
||||||
<div className={`h-[300px] w-full lg:w-[65%] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
<div className="w-full lg:w-[65%]">
|
||||||
|
<div className="flex flex-wrap items-center gap-0.5 pb-1">
|
||||||
|
<LegendChip color={chartColors.regular}>Regular hours</LegendChip>
|
||||||
|
<LegendChip color={chartColors.overtime}>Overtime</LegendChip>
|
||||||
|
<LegendChip color={chartColors.fte} dashed>
|
||||||
|
FTE
|
||||||
|
</LegendChip>
|
||||||
|
</div>
|
||||||
|
<div className="h-[256px] relative">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<ComposedChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}>
|
<ComposedChart data={chartData} margin={{ top: 8, right: 0, left: -8, bottom: 0 }}>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="label"
|
dataKey="label"
|
||||||
@@ -561,13 +581,13 @@ const PayrollMetrics = () => {
|
|||||||
tick={{ fill: "currentColor" }}
|
tick={{ fill: "currentColor" }}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<PayrollTrendTooltip />} />
|
<Tooltip content={<PayrollTrendTooltip />} />
|
||||||
<Legend />
|
|
||||||
<Bar
|
<Bar
|
||||||
yAxisId="hours"
|
yAxisId="hours"
|
||||||
dataKey="regular"
|
dataKey="regular"
|
||||||
name="Regular Hours"
|
name="Regular Hours"
|
||||||
stackId="hours"
|
stackId="hours"
|
||||||
fill={chartColors.regular}
|
fill={chartColors.regular}
|
||||||
|
shape={RegularBarShape}
|
||||||
/>
|
/>
|
||||||
<Bar
|
<Bar
|
||||||
yAxisId="hours"
|
yAxisId="hours"
|
||||||
@@ -575,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"
|
||||||
@@ -587,6 +608,7 @@ const PayrollMetrics = () => {
|
|||||||
/>
|
/>
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full lg:w-[35%]">
|
<div className="w-full lg:w-[35%]">
|
||||||
<PayrollEmployeeSummary
|
<PayrollEmployeeSummary
|
||||||
@@ -623,7 +645,7 @@ const ICON_MAP = {
|
|||||||
|
|
||||||
function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
|
function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full dashboard-stagger">
|
||||||
{cards.map((card) => (
|
{cards.map((card) => (
|
||||||
<DashboardStatCard
|
<DashboardStatCard
|
||||||
key={card.key}
|
key={card.key}
|
||||||
@@ -649,7 +671,7 @@ function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) {
|
|||||||
|
|
||||||
function SkeletonStats() {
|
function SkeletonStats() {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 w-full">
|
||||||
{Array.from({ length: 4 }).map((_, index) => (
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
||||||
))}
|
))}
|
||||||
@@ -719,8 +741,8 @@ const PayrollTrendTooltip = ({ active, payload, label }: TooltipProps<number, st
|
|||||||
|
|
||||||
function EmployeeTableSkeleton() {
|
function EmployeeTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden`}>
|
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] overflow-hidden">
|
||||||
<div className="p-3 border-b bg-muted/30">
|
<div className="p-3 border-b border-[#f0eeea]">
|
||||||
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
<div className="h-4 w-32 bg-muted rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2">
|
<div className="p-2">
|
||||||
@@ -769,41 +791,31 @@ function PayrollEmployeeSummary({
|
|||||||
|
|
||||||
if (sortedEmployees.length === 0) {
|
if (sortedEmployees.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} flex items-center justify-center`}>
|
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] flex items-center justify-center">
|
||||||
<p className="text-sm text-muted-foreground">No employee data</p>
|
<p className="text-sm text-muted-foreground">No employee data</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const periodLabel = periodCount === 1
|
|
||||||
? "1 period"
|
|
||||||
: `${periodCount} periods`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`h-[300px] rounded-lg border ${CARD_STYLES.base} overflow-hidden flex flex-col`}>
|
<div className="h-[280px] rounded-xl border border-[#efede9] bg-[#faf9f7] overflow-hidden flex flex-col">
|
||||||
<div className="p-3 border-b bg-muted/30 flex-none">
|
|
||||||
<h4 className="text-sm font-medium">
|
|
||||||
Employee Summary
|
|
||||||
<span className="text-muted-foreground font-normal ml-1">({periodLabel})</span>
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader className="sticky top-0 z-10 bg-[#faf9f7]">
|
||||||
<TableRow className="hover:bg-transparent">
|
<TableRow className="hover:bg-transparent">
|
||||||
<TableHead className="h-8 text-xs px-3">Name</TableHead>
|
<TableHead className="h-8 px-3 text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Name</TableHead>
|
||||||
{hasWeekData ? (
|
{hasWeekData ? (
|
||||||
<>
|
<>
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">Wk 1</TableHead>
|
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Wk 1</TableHead>
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">Wk 2</TableHead>
|
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Wk 2</TableHead>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">Regular</TableHead>
|
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Regular</TableHead>
|
||||||
)}
|
)}
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">Total</TableHead>
|
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Total</TableHead>
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">OT</TableHead>
|
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">OT</TableHead>
|
||||||
{periodCount > 1 && (
|
{periodCount > 1 && (
|
||||||
<TableHead className="h-8 text-xs px-2 text-right">Avg/Per</TableHead>
|
<TableHead className="h-8 px-2 text-right text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">Avg/Per</TableHead>
|
||||||
)}
|
)}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|||||||
@@ -98,61 +98,70 @@ const PeriodSelectionPopover = ({
|
|||||||
return (
|
return (
|
||||||
<Popover open={open} onOpenChange={onOpenChange}>
|
<Popover open={open} onOpenChange={onOpenChange}>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="outline" className="h-9">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 gap-1.5 rounded-full border-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
|
||||||
|
>
|
||||||
{selectedLabel}
|
{selectedLabel}
|
||||||
<ChevronDown className="w-4 h-4 text-muted-foreground" />
|
<ChevronDown className="h-3.5 w-3.5 text-[#a09b92]" />
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent className="w-96 p-4" align="end">
|
<PopoverContent className="w-96 rounded-[14px] border-[#e7e5e1] p-4 shadow-lg" align="end">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">Select Time Period</div>
|
<div className="text-[10.5px] font-semibold uppercase tracking-wide text-[#a09b92]">
|
||||||
|
Time period
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={isLast30DaysActive ? "default" : "outline"}
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleQuickSelect("last30days")}
|
onClick={() => handleQuickSelect("last30days")}
|
||||||
className="h-8 text-xs"
|
className={`h-8 rounded-full text-xs shadow-none ${
|
||||||
|
isLast30DaysActive
|
||||||
|
? "border-[#2b2925] bg-[#2b2925] text-white hover:bg-[#2b2925]/90 hover:text-white"
|
||||||
|
: "border-[#e7e5e1] text-[#2b2925] hover:bg-[#faf9f7]"
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Last 30 Days
|
Last 30 Days
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
|
||||||
onClick={() => handleQuickSelect("thisMonth")}
|
onClick={() => handleQuickSelect("thisMonth")}
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
>
|
||||||
This Month
|
This Month
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
|
||||||
onClick={() => handleQuickSelect("lastMonth")}
|
onClick={() => handleQuickSelect("lastMonth")}
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
>
|
||||||
Last Month
|
Last Month
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
|
||||||
onClick={() => handleQuickSelect("thisQuarter")}
|
onClick={() => handleQuickSelect("thisQuarter")}
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
>
|
||||||
This Quarter
|
This Quarter
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
|
||||||
onClick={() => handleQuickSelect("lastQuarter")}
|
onClick={() => handleQuickSelect("lastQuarter")}
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
>
|
||||||
Last Quarter
|
Last Quarter
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8 rounded-full border-[#e7e5e1] text-xs text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
|
||||||
onClick={() => handleQuickSelect("thisYear")}
|
onClick={() => handleQuickSelect("thisYear")}
|
||||||
className="h-8 text-xs"
|
|
||||||
>
|
>
|
||||||
This Year
|
This Year
|
||||||
</Button>
|
</Button>
|
||||||
@@ -161,26 +170,26 @@ const PeriodSelectionPopover = ({
|
|||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs text-muted-foreground">Or enter a custom period:</div>
|
<div className="text-xs text-[#7c7870]">Or enter a custom period:</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(event) => handleInputChange(event.target.value)}
|
onChange={(event) => handleInputChange(event.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
className="h-8 text-sm"
|
className="h-8 rounded-full border-[#e7e5e1] px-3.5 text-sm shadow-none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{inputValue && (
|
{inputValue && (
|
||||||
<div className="mt-2 ml-3">
|
<div className="mt-2 ml-3">
|
||||||
{preview.label ? (
|
{preview.label ? (
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span className="font-medium text-green-600 dark:text-green-400">
|
<span className="font-medium text-[#237a4d]">
|
||||||
{preview.label}
|
{preview.label}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-amber-600 dark:text-amber-400">
|
<div className="text-xs text-[#a87a24]">
|
||||||
Not recognized
|
Not recognized
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Lock, Delete } from "lucide-react";
|
import { Lock, Delete } from "lucide-react";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
const MAX_ATTEMPTS = 3;
|
const MAX_ATTEMPTS = 3;
|
||||||
const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
const LOCKOUT_DURATION = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||||
@@ -27,8 +27,6 @@ const PinProtection = ({ onSuccess }) => {
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let timer;
|
let timer;
|
||||||
if (lockoutTime > 0) {
|
if (lockoutTime > 0) {
|
||||||
@@ -59,34 +57,25 @@ const PinProtection = ({ onSuccess }) => {
|
|||||||
|
|
||||||
if (newAttempts >= MAX_ATTEMPTS) {
|
if (newAttempts >= MAX_ATTEMPTS) {
|
||||||
setLockoutTime(LOCKOUT_DURATION);
|
setLockoutTime(LOCKOUT_DURATION);
|
||||||
toast({
|
toast.error("Too many attempts", {
|
||||||
title: "Too many attempts",
|
|
||||||
description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`,
|
description: `Please try again in ${Math.ceil(LOCKOUT_DURATION / 60000)} minutes`,
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
setPin("");
|
setPin("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (value === "892312") {
|
if (value === "892312") {
|
||||||
toast({
|
toast.success("PIN accepted");
|
||||||
title: "Success",
|
|
||||||
description: "PIN accepted",
|
|
||||||
});
|
|
||||||
// Reset attempts on success
|
// Reset attempts on success
|
||||||
setAttempts(0);
|
setAttempts(0);
|
||||||
localStorage.removeItem('pinAttempts');
|
localStorage.removeItem('pinAttempts');
|
||||||
localStorage.removeItem('lastAttemptTime');
|
localStorage.removeItem('lastAttemptTime');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast.error(`Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`);
|
||||||
title: "Error",
|
|
||||||
description: `Incorrect PIN. ${MAX_ATTEMPTS - newAttempts} attempts remaining`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
setPin("");
|
setPin("");
|
||||||
}
|
}
|
||||||
}, [attempts, lockoutTime, onSuccess, toast]);
|
}, [attempts, lockoutTime, onSuccess]);
|
||||||
|
|
||||||
const handleKeyPress = (value) => {
|
const handleKeyPress = (value) => {
|
||||||
if (pin.length < 6) {
|
if (pin.length < 6) {
|
||||||
|
|||||||
@@ -1,376 +1,211 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import axios from "axios";
|
|
||||||
import { acotService } from "@/services/dashboard/acotService";
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { Package, Search, X } from "lucide-react";
|
||||||
import { Loader2, ArrowUpDown, AlertCircle, Package, Settings2, Search, X } from "lucide-react";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "@/components/ui/table";
|
|
||||||
import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
|
import { BusinessRangeSelect } from "@/components/dashboard/shared/BusinessRangeSelect";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { CARD_STYLES, TYPOGRAPHY } from "@/lib/dashboard/designTokens";
|
|
||||||
import { DashboardEmptyState, DashboardErrorState } from "@/components/dashboard/shared";
|
import { DashboardEmptyState, DashboardErrorState } from "@/components/dashboard/shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top Sellers — ranked product list (Sorbet Studio).
|
||||||
|
* Rank badges cycle the pastel tile fills; revenue is the bold right column.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RANK_FILLS = ["#fbe7de", "#ddf0ea", "#faf0d7", "#eae6f6"];
|
||||||
|
|
||||||
|
const SORTS = [
|
||||||
|
{ key: "totalQuantity", label: "Sold" },
|
||||||
|
{ key: "totalRevenue", label: "Revenue" },
|
||||||
|
];
|
||||||
|
|
||||||
const ProductGrid = ({
|
const ProductGrid = ({
|
||||||
timeRange = "today",
|
timeRange = "today",
|
||||||
onTimeRangeChange,
|
onTimeRangeChange,
|
||||||
title = "Top Products",
|
title = "Top sellers",
|
||||||
description
|
|
||||||
}) => {
|
}) => {
|
||||||
const [products, setProducts] = useState([]);
|
const [products, setProducts] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange);
|
const [selectedTimeRange, setSelectedTimeRange] = useState(timeRange);
|
||||||
const [sorting, setSorting] = useState({
|
const [sortKey, setSortKey] = useState("totalQuantity");
|
||||||
column: "totalQuantity",
|
|
||||||
direction: "desc",
|
|
||||||
});
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProducts();
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const response = await acotService.getProducts({ timeRange: selectedTimeRange });
|
||||||
|
if (!cancelled) setProducts(response.stats.products.list || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching products:", err);
|
||||||
|
if (!cancelled) setError(err.message);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
}, [selectedTimeRange]);
|
}, [selectedTimeRange]);
|
||||||
|
|
||||||
const fetchProducts = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const response = await acotService.getProducts({ timeRange: selectedTimeRange });
|
|
||||||
setProducts(response.stats.products.list || []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching products:", error);
|
|
||||||
setError(error.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimeRangeChange = (value) => {
|
const handleTimeRangeChange = (value) => {
|
||||||
setSelectedTimeRange(value);
|
setSelectedTimeRange(value);
|
||||||
if (onTimeRangeChange) {
|
if (onTimeRangeChange) onTimeRangeChange(value);
|
||||||
onTimeRangeChange(value);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSort = (column) => {
|
const ranked = [...products].sort((a, b) => (b[sortKey] || 0) - (a[sortKey] || 0));
|
||||||
setSorting((prev) => ({
|
const filtered = ranked.filter((product) =>
|
||||||
column,
|
|
||||||
direction:
|
|
||||||
prev.column === column && prev.direction === "desc" ? "asc" : "desc",
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedProducts = [...products].sort((a, b) => {
|
|
||||||
const direction = sorting.direction === "desc" ? -1 : 1;
|
|
||||||
const aValue = a[sorting.column];
|
|
||||||
const bValue = b[sorting.column];
|
|
||||||
|
|
||||||
if (typeof aValue === "number") {
|
|
||||||
return (aValue - bValue) * direction;
|
|
||||||
}
|
|
||||||
return String(aValue).localeCompare(String(bValue)) * direction;
|
|
||||||
});
|
|
||||||
|
|
||||||
const filteredProducts = sortedProducts.filter(product =>
|
|
||||||
product.name.toLowerCase().includes(searchQuery.toLowerCase())
|
product.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
const SkeletonProduct = () => (
|
|
||||||
<tr className="hover:bg-muted/50 transition-colors">
|
|
||||||
<td className="p-1 align-middle w-[50px]">
|
|
||||||
<Skeleton className="h-[50px] w-[50px] rounded bg-muted" />
|
|
||||||
</td>
|
|
||||||
<td className="p-1 align-middle min-w-[200px]">
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Skeleton className="h-4 w-[180px] bg-muted rounded-sm" />
|
|
||||||
<Skeleton className="h-3 w-[140px] bg-muted rounded-sm" />
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-1 align-middle text-center">
|
|
||||||
<Skeleton className="h-4 w-8 mx-auto bg-muted rounded-sm" />
|
|
||||||
</td>
|
|
||||||
<td className="p-1 align-middle text-center">
|
|
||||||
<Skeleton className="h-4 w-16 mx-auto bg-muted rounded-sm" />
|
|
||||||
</td>
|
|
||||||
<td className="p-1 align-middle text-center">
|
|
||||||
<Skeleton className="h-4 w-8 mx-auto bg-muted rounded-sm" />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
|
|
||||||
const LoadingState = () => (
|
|
||||||
<div className="h-full">
|
|
||||||
<div className="overflow-y-auto h-full">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr className="hover:bg-transparent">
|
|
||||||
<th className="p-1.5 text-left font-medium sticky top-0 bg-card z-10 w-[50px] min-w-[50px] border-b border-border/50" />
|
|
||||||
<th className="p-1.5 text-left font-medium sticky top-0 bg-card z-10 min-w-[200px] border-b border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full p-2 justify-start h-8 pointer-events-none"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Skeleton className="h-4 w-12 bg-muted rounded-sm" />
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-1.5 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full p-2 justify-center h-8 pointer-events-none"
|
|
||||||
disabled
|
|
||||||
>
|
|
||||||
<Skeleton className="h-4 w-16 bg-muted rounded-sm" />
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border/50">
|
|
||||||
{[...Array(20)].map((_, i) => (
|
|
||||||
<SkeletonProduct key={i} />
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
|
|
||||||
<CardHeader className="p-6 pb-4">
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>
|
|
||||||
<Skeleton className="h-6 w-32 bg-muted rounded-sm" />
|
|
||||||
</CardTitle>
|
|
||||||
{description && (
|
|
||||||
<CardDescription className="mt-1">
|
|
||||||
<Skeleton className="h-4 w-48 bg-muted rounded-sm" />
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Skeleton className="h-9 w-9 bg-muted rounded-sm" />
|
|
||||||
<Skeleton className="h-9 w-[130px] bg-muted rounded-sm" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
|
|
||||||
<div className="h-full">
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
|
<Card className={`flex flex-col h-full ${CARD_STYLES.base}`}>
|
||||||
<CardHeader className="p-6 pb-4">
|
<CardHeader className="border-b border-[#f0eeea] px-4 py-3">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<div className="flex justify-between items-start">
|
<CardTitle className="text-[12.5px] font-semibold tracking-tight text-[#2b2925]">
|
||||||
<div>
|
{title}
|
||||||
<CardTitle className={TYPOGRAPHY.sectionTitle}>{title}</CardTitle>
|
</CardTitle>
|
||||||
{description && (
|
<div className="flex items-center gap-1.5">
|
||||||
<CardDescription className={`mt-1 ${TYPOGRAPHY.cardDescription}`}>{description}</CardDescription>
|
<div className="flex items-center gap-0.5">
|
||||||
)}
|
{SORTS.map(({ key, label }) => (
|
||||||
</div>
|
<button
|
||||||
<div className="flex items-center gap-2">
|
key={key}
|
||||||
{!error && (
|
type="button"
|
||||||
<Button
|
onClick={() => setSortKey(key)}
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setIsSearchVisible(!isSearchVisible)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-9 w-9",
|
"h-7 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||||
isSearchVisible && "bg-muted"
|
sortKey === key
|
||||||
|
? "bg-[#f0eeea] text-[#2b2925]"
|
||||||
|
: "text-[#a09b92] hover:bg-[#f5f3f0] hover:text-[#2b2925]"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Search className="h-4 w-4" />
|
{label}
|
||||||
</Button>
|
</button>
|
||||||
)}
|
))}
|
||||||
<BusinessRangeSelect
|
|
||||||
value={selectedTimeRange}
|
|
||||||
onValueChange={handleTimeRangeChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
{!error && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setIsSearchVisible(!isSearchVisible)}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-8 rounded-full text-[#7c7870] hover:bg-[#f5f3f0]",
|
||||||
|
isSearchVisible && "bg-[#f0eeea] text-[#2b2925]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Search className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<BusinessRangeSelect
|
||||||
|
value={selectedTimeRange}
|
||||||
|
onValueChange={handleTimeRangeChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isSearchVisible && !error && (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search products..."
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="pl-9 pr-9 h-9 w-full"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
{searchQuery && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-1 top-1 h-7 w-7"
|
|
||||||
onClick={() => setSearchQuery("")}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
{isSearchVisible && !error && (
|
||||||
|
<div className="relative w-full pt-1.5">
|
||||||
|
<Search className="absolute left-3 top-[15px] h-3.5 w-3.5 text-[#a09b92]" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search products…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="h-8 w-full rounded-full border-[#e7e5e1] pl-9 pr-9 text-xs shadow-none"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-[7px] h-7 w-7 rounded-full"
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0 flex-1 overflow-hidden -mt-1">
|
<CardContent className="flex-1 overflow-hidden p-0">
|
||||||
<div className="h-full">
|
{loading ? (
|
||||||
{error ? (
|
<div>
|
||||||
<DashboardErrorState error={`Failed to load products: ${error}`} className="mx-0 my-0" />
|
{[...Array(8)].map((_, i) => (
|
||||||
) : !products?.length ? (
|
<div
|
||||||
<DashboardEmptyState
|
key={i}
|
||||||
icon={Package}
|
className="flex items-center gap-3 border-b border-[#f5f3f0] px-4 py-2.5 last:border-b-0"
|
||||||
title="No product data available"
|
>
|
||||||
description="Try selecting a different time range"
|
<div className="h-7 w-7 animate-pulse rounded-lg bg-[#f0eeea]" />
|
||||||
height="sm"
|
<div className="h-9 w-9 animate-pulse rounded-lg bg-[#f5f3f0]" />
|
||||||
/>
|
<div className="flex-1">
|
||||||
) : (
|
<div className="h-3.5 w-3/4 animate-pulse rounded bg-[#f0eeea]" />
|
||||||
<div className="h-full">
|
<div className="mt-1.5 h-3 w-24 animate-pulse rounded bg-[#f5f3f0]" />
|
||||||
<div className="overflow-y-auto h-full">
|
</div>
|
||||||
<table className="w-full">
|
<div className="h-4 w-14 animate-pulse rounded bg-[#f0eeea]" />
|
||||||
<thead>
|
|
||||||
<tr className="hover:bg-transparent">
|
|
||||||
<th className="p-1 text-left font-medium sticky top-0 bg-card z-10 h-[50px] min-h-[50px] w-[50px] min-w-[35px] border-b border-border/50" />
|
|
||||||
<th className="p-1 text-left font-medium sticky top-0 bg-card z-10 border-b border-border/50">
|
|
||||||
<Button
|
|
||||||
variant={sorting.column === "name" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("name")}
|
|
||||||
className="w-full p-2 justify-start h-8"
|
|
||||||
>
|
|
||||||
Product
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
|
||||||
<Button
|
|
||||||
variant={sorting.column === "totalQuantity" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("totalQuantity")}
|
|
||||||
className="w-full p-2 justify-center h-8"
|
|
||||||
>
|
|
||||||
Sold
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
|
||||||
<Button
|
|
||||||
variant={sorting.column === "totalRevenue" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("totalRevenue")}
|
|
||||||
className="w-full p-2 justify-center h-8"
|
|
||||||
>
|
|
||||||
Rev
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
<th className="p-1 font-medium text-center sticky top-0 bg-card z-10 border-b border-border/50">
|
|
||||||
<Button
|
|
||||||
variant={sorting.column === "orderCount" ? "default" : "ghost"}
|
|
||||||
onClick={() => handleSort("orderCount")}
|
|
||||||
className="w-full p-2 justify-center h-8"
|
|
||||||
>
|
|
||||||
Orders
|
|
||||||
</Button>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-border/50">
|
|
||||||
{filteredProducts.map((product) => (
|
|
||||||
<tr
|
|
||||||
key={product.id}
|
|
||||||
className="hover:bg-muted/50 transition-colors"
|
|
||||||
>
|
|
||||||
<td className="p-1 align-middle w-[50px]">
|
|
||||||
{product.ImgThumb && (
|
|
||||||
<img
|
|
||||||
src={product.ImgThumb}
|
|
||||||
alt=""
|
|
||||||
width={50}
|
|
||||||
height={50}
|
|
||||||
className="rounded bg-muted w-[50px] h-[50px] object-contain"
|
|
||||||
onError={(e) => (e.target.style.display = "none")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="p-1 align-middle min-w-[200px]">
|
|
||||||
<div className="flex flex-col min-w-0">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<a
|
|
||||||
href={`https://backend.acherryontop.com/product/${product.id}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-sm hover:underline line-clamp-2 text-foreground"
|
|
||||||
>
|
|
||||||
{product.name}
|
|
||||||
</a>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-[300px]">
|
|
||||||
<p>{product.name}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="p-1 align-middle text-center text-sm font-medium text-foreground">
|
|
||||||
{product.totalQuantity}
|
|
||||||
</td>
|
|
||||||
<td className="p-1 align-middle text-center text-emerald-600 dark:text-emerald-400 text-sm font-medium">
|
|
||||||
${product.totalRevenue.toFixed(2)}
|
|
||||||
</td>
|
|
||||||
<td className="p-1 align-middle text-center text-muted-foreground text-sm">
|
|
||||||
{product.orderCount}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
)}
|
</div>
|
||||||
</div>
|
) : error ? (
|
||||||
|
<DashboardErrorState error={`Failed to load products: ${error}`} className="m-4" />
|
||||||
|
) : !filtered.length ? (
|
||||||
|
<DashboardEmptyState
|
||||||
|
icon={Package}
|
||||||
|
title="No product data available"
|
||||||
|
description="Try selecting a different time range"
|
||||||
|
height="sm"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-full overflow-y-auto">
|
||||||
|
{filtered.map((product, index) => (
|
||||||
|
<a
|
||||||
|
key={product.id}
|
||||||
|
href={`https://backend.acherryontop.com/product/${product.id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 border-b border-[#f5f3f0] px-4 py-2 transition-colors last:border-b-0 hover:bg-[#faf9f7]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-xs font-bold tabular-nums text-[#2b2925]"
|
||||||
|
style={{
|
||||||
|
background: index < 4 ? RANK_FILLS[index] : "#f5f3f0",
|
||||||
|
color: index < 4 ? "#2b2925" : "#a09b92",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</span>
|
||||||
|
{product.ImgThumb ? (
|
||||||
|
<img
|
||||||
|
src={product.ImgThumb}
|
||||||
|
alt=""
|
||||||
|
width={36}
|
||||||
|
height={36}
|
||||||
|
className="h-9 w-9 shrink-0 rounded-lg bg-[#faf9f7] object-contain"
|
||||||
|
onError={(e) => (e.target.style.visibility = "hidden")}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="h-9 w-9 shrink-0 rounded-lg bg-[#faf9f7]" />
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-[13px] font-medium text-[#2b2925]">
|
||||||
|
{product.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-[#7c7870]">
|
||||||
|
{product.totalQuantity} sold · {product.orderCount}{" "}
|
||||||
|
{product.orderCount === 1 ? "order" : "orders"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 text-[13px] font-bold tabular-nums text-[#2b2925]">
|
||||||
|
${product.totalRevenue.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,23 +8,20 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
|
PieChart,
|
||||||
|
Pie,
|
||||||
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
import {
|
|
||||||
Tooltip as UITooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
TooltipProvider,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { format } from "date-fns";
|
import { format } from "date-fns";
|
||||||
|
import { Radio, Users } from "lucide-react";
|
||||||
|
|
||||||
// Import shared components and tokens
|
// Import shared components and tokens
|
||||||
import {
|
import {
|
||||||
DashboardChartTooltip,
|
DashboardChartTooltip,
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardStatCard,
|
|
||||||
DashboardTable,
|
DashboardTable,
|
||||||
StatCardSkeleton,
|
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
@@ -36,15 +33,15 @@ import {
|
|||||||
// Realtime-specific colors using the standardized palette
|
// Realtime-specific colors using the standardized palette
|
||||||
const REALTIME_COLORS = {
|
const REALTIME_COLORS = {
|
||||||
activeUsers: {
|
activeUsers: {
|
||||||
color: METRIC_COLORS.aov, // Purple
|
color: METRIC_COLORS.revenue, // Studio coral — realtime is a headline metric
|
||||||
className: "text-chart-aov",
|
|
||||||
},
|
|
||||||
pages: {
|
|
||||||
color: METRIC_COLORS.revenue, // Emerald
|
|
||||||
className: "text-chart-revenue",
|
className: "text-chart-revenue",
|
||||||
},
|
},
|
||||||
|
pages: {
|
||||||
|
color: METRIC_COLORS.orders, // Studio violet
|
||||||
|
className: "text-chart-orders",
|
||||||
|
},
|
||||||
sources: {
|
sources: {
|
||||||
color: METRIC_COLORS.comparison, // Amber
|
color: METRIC_COLORS.comparison, // Studio teal
|
||||||
className: "text-chart-comparison",
|
className: "text-chart-comparison",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -52,10 +49,6 @@ const REALTIME_COLORS = {
|
|||||||
// Export for backwards compatibility
|
// Export for backwards compatibility
|
||||||
export { REALTIME_COLORS as METRIC_COLORS };
|
export { REALTIME_COLORS as METRIC_COLORS };
|
||||||
|
|
||||||
export const SkeletonSummaryCard = () => (
|
|
||||||
<StatCardSkeleton size="default" hasIcon={false} hasSubtitle />
|
|
||||||
);
|
|
||||||
|
|
||||||
export const SkeletonBarChart = () => (
|
export const SkeletonBarChart = () => (
|
||||||
<ChartSkeleton type="bar" height="sm" withCard={false} />
|
<ChartSkeleton type="bar" height="sm" withCard={false} />
|
||||||
);
|
);
|
||||||
@@ -192,6 +185,87 @@ export const QuotaInfo = ({ tokenQuota }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Traffic-sources donut — mirrors the Device Usage tab in UserBehavior:
|
||||||
|
// ring with a center total and a labeled legend column (no on-slice labels).
|
||||||
|
const SOURCE_COLORS = ["#e06a4e", "#6b5fc7", "#0f8f77", "#a87a24", "#3f7fae", "#c94f76"];
|
||||||
|
|
||||||
|
const SourcesDonut = ({ sources, loading }) => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center gap-6">
|
||||||
|
<div className="h-40 w-40 shrink-0 animate-pulse rounded-full bg-[#f0eeea]" />
|
||||||
|
<div className="flex-1 space-y-2.5">
|
||||||
|
{[...Array(4)].map((_, i) => (
|
||||||
|
<div key={i} className="h-3.5 w-full animate-pulse rounded bg-[#f0eeea]" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const top = [...(sources || [])]
|
||||||
|
.sort((a, b) => b.activeUsers - a.activeUsers)
|
||||||
|
.slice(0, 6);
|
||||||
|
const total = top.reduce((sum, s) => sum + s.activeUsers, 0);
|
||||||
|
|
||||||
|
if (!total) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-[#a09b92]">
|
||||||
|
No active sources
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full items-center gap-4 sm:gap-6">
|
||||||
|
<div className="relative h-40 w-40 shrink-0">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={top}
|
||||||
|
dataKey="activeUsers"
|
||||||
|
nameKey="source"
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={76}
|
||||||
|
paddingAngle={2}
|
||||||
|
stroke="#ffffff"
|
||||||
|
strokeWidth={2}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{top.map((entry, index) => (
|
||||||
|
<Cell key={entry.source} fill={SOURCE_COLORS[index % SOURCE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-[19px] font-bold tabular-nums text-[#2b2925]">{total}</span>
|
||||||
|
<span className="text-[10px] font-semibold text-[#a09b92]">active</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2 overflow-y-auto dashboard-scroll" style={{ maxHeight: 176 }}>
|
||||||
|
{top.map((entry, index) => (
|
||||||
|
<div key={entry.source} className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ background: SOURCE_COLORS[index % SOURCE_COLORS.length] }}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate text-xs font-medium text-[#2b2925]">
|
||||||
|
{entry.source}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs tabular-nums text-[#7c7870]">{entry.activeUsers}</span>
|
||||||
|
<span className="w-10 text-right text-xs font-bold tabular-nums text-[#2b2925]">
|
||||||
|
{Math.round((entry.activeUsers / total) * 100)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Custom tooltip for the realtime chart
|
// Custom tooltip for the realtime chart
|
||||||
const RealtimeTooltip = ({ active, payload }) => {
|
const RealtimeTooltip = ({ active, payload }) => {
|
||||||
if (active && payload && payload.length) {
|
if (active && payload && payload.length) {
|
||||||
@@ -342,43 +416,29 @@ export const RealtimeAnalytics = () => {
|
|||||||
{
|
{
|
||||||
key: "path",
|
key: "path",
|
||||||
header: "Page",
|
header: "Page",
|
||||||
|
// GA4's realtime API only exposes unifiedScreenName (page TITLE), not
|
||||||
|
// pagePath — so these can't be linked, unlike the 30-day Top Pages table.
|
||||||
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "activeUsers",
|
key: "activeUsers",
|
||||||
header: "Active Users",
|
header: "Active Users",
|
||||||
align: "right",
|
align: "right",
|
||||||
|
width: "w-[110px] min-w-[110px] whitespace-nowrap",
|
||||||
render: (value) => <span className={REALTIME_COLORS.pages.className}>{value}</span>,
|
render: (value) => <span className={REALTIME_COLORS.pages.className}>{value}</span>,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Column definitions for sources table
|
|
||||||
const sourcesColumns = [
|
|
||||||
{
|
|
||||||
key: "source",
|
|
||||||
header: "Source",
|
|
||||||
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "activeUsers",
|
|
||||||
header: "Active Users",
|
|
||||||
align: "right",
|
|
||||||
render: (value) => <span className={REALTIME_COLORS.sources.className}>{value}</span>,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (loading && !basicData && !detailedData) {
|
if (loading && !basicData && !detailedData) {
|
||||||
return (
|
return (
|
||||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
<Card className={`${CARD_STYLES.base} h-full`}>
|
||||||
<DashboardSectionHeader title="Real-Time Analytics" className="pb-2" />
|
<DashboardSectionHeader title="Real-Time Analytics" size="large" />
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0">
|
<CardContent className="p-4 pt-3">
|
||||||
<div className="grid grid-cols-2 gap-4 mt-1 mb-3">
|
<div className="mb-3 h-[74px] animate-pulse rounded-xl bg-[#f0eeea]" />
|
||||||
<SkeletonSummaryCard />
|
|
||||||
<SkeletonSummaryCard />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[...Array(3)].map((_, i) => (
|
{[...Array(3)].map((_, i) => (
|
||||||
<div key={i} className="h-8 w-20 bg-muted animate-pulse rounded-md" />
|
<div key={i} className="h-8 w-20 bg-muted animate-pulse rounded-md" />
|
||||||
@@ -395,57 +455,55 @@ export const RealtimeAnalytics = () => {
|
|||||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
<Card className={`${CARD_STYLES.base} h-full`}>
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="Real-Time Analytics"
|
title="Real-Time Analytics"
|
||||||
className="pb-2"
|
size="large"
|
||||||
actions={
|
actions={
|
||||||
<TooltipProvider>
|
<span className={TYPOGRAPHY.label}>
|
||||||
<UITooltip>
|
Updated{" "}
|
||||||
<TooltipTrigger>
|
{basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")}
|
||||||
<div className={TYPOGRAPHY.label}>
|
</span>
|
||||||
Last updated:{" "}
|
|
||||||
{basicData.lastUpdated && format(new Date(basicData.lastUpdated), "h:mm a")}
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="p-3">
|
|
||||||
<QuotaInfo tokenQuota={basicData.tokenQuota} />
|
|
||||||
</TooltipContent>
|
|
||||||
</UITooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0">
|
<CardContent className="p-4 pt-3">
|
||||||
{error && (
|
{error && (
|
||||||
<DashboardErrorState error={error} className="mx-0 mb-4" />
|
<DashboardErrorState error={error} className="mx-0 mb-4" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 mt-1 mb-3 dashboard-stagger">
|
<div className="mb-3 grid grid-cols-[1fr_auto] items-center gap-4 rounded-xl bg-[#e6f3ee] px-4 py-3">
|
||||||
<DashboardStatCard
|
<div className="flex items-center gap-3">
|
||||||
title="Last 30 minutes"
|
<div className="relative flex h-9 w-9 items-center justify-center rounded-full bg-white">
|
||||||
subtitle="Active users"
|
<Radio className="h-4 w-4 text-[#1f8a67]" />
|
||||||
value={basicData.last30MinUsers}
|
<span className="absolute right-0 top-0 h-2 w-2 rounded-full bg-[#1f8a67] ring-2 ring-[#e6f3ee]" />
|
||||||
size="large"
|
</div>
|
||||||
/>
|
<div>
|
||||||
<DashboardStatCard
|
<p className="text-[11px] font-semibold text-[#2b2925]/60">Active now</p>
|
||||||
title="Last 5 minutes"
|
<p className="text-3xl font-bold leading-none tracking-tight tabular-nums text-[#2b2925]">
|
||||||
subtitle="Active users"
|
{basicData.last5MinUsers}
|
||||||
value={basicData.last5MinUsers}
|
</p>
|
||||||
size="large"
|
</div>
|
||||||
/>
|
</div>
|
||||||
|
<div className="border-l border-[#cfe5dc] pl-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5 text-muted-foreground">
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
<span className="text-[11px]">30 minutes</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg font-semibold tabular-nums">{basicData.last30MinUsers}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="activity" className="w-full">
|
<Tabs defaultValue="activity" className="w-full">
|
||||||
<TabsList className="mb-4">
|
<TabsList className="mb-3 h-8">
|
||||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||||
<TabsTrigger value="pages">Current Pages</TabsTrigger>
|
<TabsTrigger value="pages">Current Pages</TabsTrigger>
|
||||||
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="activity">
|
<TabsContent value="activity">
|
||||||
<div className="h-[235px] rounded-lg">
|
<div className="h-[176px] rounded-lg">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart
|
<BarChart
|
||||||
data={basicData.byMinute}
|
data={basicData.byMinute}
|
||||||
margin={{ top: 5, right: 5, left: -35, bottom: -5 }}
|
margin={{ top: 8, right: 0, left: -28, bottom: -5 }}
|
||||||
>
|
>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="minute"
|
dataKey="minute"
|
||||||
@@ -471,28 +529,20 @@ export const RealtimeAnalytics = () => {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="pages">
|
<TabsContent value="pages">
|
||||||
<div className="h-[230px]">
|
<div className="h-[188px] overflow-y-auto dashboard-scroll">
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
columns={pagesColumns}
|
columns={pagesColumns}
|
||||||
data={detailedData.currentPages}
|
data={detailedData.currentPages}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
getRowKey={(page, index) => `${page.path}-${index}`}
|
getRowKey={(page, index) => `${page.path}-${index}`}
|
||||||
maxHeight="sm"
|
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sources">
|
<TabsContent value="sources">
|
||||||
<div className="h-[230px]">
|
<div className="h-[188px]">
|
||||||
<DashboardTable
|
<SourcesDonut sources={detailedData.sources} loading={loading} />
|
||||||
columns={sourcesColumns}
|
|
||||||
data={detailedData.sources}
|
|
||||||
loading={loading}
|
|
||||||
getRowKey={(source, index) => `${source.source}-${index}`}
|
|
||||||
maxHeight="sm"
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -501,4 +551,60 @@ export const RealtimeAnalytics = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RealtimeChip — compact live-visitors pill for the page header. The full
|
||||||
|
* realtime panel (chart + pages + sources tabs) lives behind a popover, per
|
||||||
|
* the Studio design: realtime is ambient chrome, not a standing section.
|
||||||
|
*/
|
||||||
|
export const RealtimeChip = () => {
|
||||||
|
const [count, setCount] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const response = await apiFetch("/api/dashboard-analytics/realtime/basic", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const result = await response.json();
|
||||||
|
if (!cancelled) setCount(processBasicData(result.data).last5MinUsers);
|
||||||
|
} catch {
|
||||||
|
// ambient chip — stay quiet on errors
|
||||||
|
}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const t = setInterval(load, 30000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearInterval(t);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="flex h-8 items-center gap-2 rounded-full bg-[#ddf0ea] px-3.5 text-xs font-semibold text-[#2b2925] transition-[filter] hover:brightness-[1.03]"
|
||||||
|
>
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full rounded-full bg-[#1f8a67] opacity-60 motion-safe:animate-ping" />
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-[#1f8a67]" />
|
||||||
|
</span>
|
||||||
|
{count != null ? `${count} on site` : "Live"}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
className="w-[460px] max-w-[92vw] rounded-[14px] border-0 p-0 shadow-xl"
|
||||||
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
|
>
|
||||||
|
<RealtimeAnalytics />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default RealtimeAnalytics;
|
export default RealtimeAnalytics;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens";
|
||||||
import {
|
import {
|
||||||
DashboardSectionHeader,
|
DashboardSectionHeader,
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
@@ -41,18 +41,19 @@ import {
|
|||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
METRIC_COLORS,
|
MetricPill,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
// Chart color mapping using semantic tokens
|
// Chart color mapping — Sorbet Studio series hues (prev-* stay dashed in the
|
||||||
|
// chart; the lighter prev tones are only legal alongside that dash)
|
||||||
const CHART_COLORS = {
|
const CHART_COLORS = {
|
||||||
revenue: METRIC_COLORS.revenue,
|
revenue: STUDIO_COLORS.coral,
|
||||||
prevRevenue: METRIC_COLORS.comparison,
|
prevRevenue: STUDIO_COLORS.teal,
|
||||||
orders: METRIC_COLORS.orders,
|
orders: STUDIO_COLORS.violet,
|
||||||
prevOrders: METRIC_COLORS.secondary,
|
prevOrders: "#9c92de",
|
||||||
aov: METRIC_COLORS.aov,
|
aov: STUDIO_COLORS.amber,
|
||||||
prevAov: METRIC_COLORS.comparison,
|
prevAov: "#c9a45b",
|
||||||
movingAverage: METRIC_COLORS.comparison,
|
movingAverage: "#a8a29a",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Move formatCurrency to top and export it
|
// Move formatCurrency to top and export it
|
||||||
@@ -224,105 +225,56 @@ const calculateSummaryStats = (data = []) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add memoized SummaryStats component
|
// Compact inline totals — replaces the old four-card summary row. The hero
|
||||||
|
// tiles above the fold already carry the headline numbers; this line just
|
||||||
|
// anchors the chart to its period totals.
|
||||||
const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => {
|
const SummaryStats = memo(({ stats = {}, projection = null, projectionLoading = false }) => {
|
||||||
const {
|
const {
|
||||||
totalRevenue = 0,
|
totalRevenue = 0,
|
||||||
totalOrders = 0,
|
totalOrders = 0,
|
||||||
avgOrderValue = 0,
|
avgOrderValue = 0,
|
||||||
bestDay = null,
|
|
||||||
prevRevenue = 0,
|
prevRevenue = 0,
|
||||||
prevOrders = 0,
|
prevOrders = 0,
|
||||||
prevAvgOrderValue = 0,
|
periodProgress = 100,
|
||||||
periodProgress = 100
|
|
||||||
} = stats;
|
} = stats;
|
||||||
|
|
||||||
// Calculate projected values when period is incomplete
|
const currentRevenue =
|
||||||
const currentRevenue = periodProgress < 100 ? (projection?.projectedRevenue || totalRevenue) : totalRevenue;
|
periodProgress < 100 ? projection?.projectedRevenue || totalRevenue : totalRevenue;
|
||||||
const revenueTrend = currentRevenue >= prevRevenue ? "up" : "down";
|
const currentOrders =
|
||||||
const revenueDiff = Math.abs(currentRevenue - prevRevenue);
|
periodProgress < 100 ? projection?.projectedOrders || totalOrders : totalOrders;
|
||||||
const revenuePercentage = (revenueDiff / prevRevenue) * 100;
|
const showTrends = !(projectionLoading && periodProgress < 100);
|
||||||
|
const revPct = prevRevenue > 0 ? ((currentRevenue - prevRevenue) / prevRevenue) * 100 : null;
|
||||||
|
const ordPct = prevOrders > 0 ? ((currentOrders - prevOrders) / prevOrders) * 100 : null;
|
||||||
|
|
||||||
// Calculate order trends
|
const Delta = ({ pct }) => {
|
||||||
const currentOrders = periodProgress < 100 ? (projection?.projectedOrders || totalOrders) : totalOrders;
|
if (!showTrends || pct == null || !isFinite(pct) || Math.abs(pct) < 0.1) return null;
|
||||||
const ordersTrend = currentOrders >= prevOrders ? "up" : "down";
|
return (
|
||||||
const ordersDiff = Math.abs(currentOrders - prevOrders);
|
<span className={pct > 0 ? "text-[#237a4d]" : "text-[#b3503f]"}>
|
||||||
const ordersPercentage = (ordersDiff / prevOrders) * 100;
|
{" "}
|
||||||
|
{pct > 0 ? "↑" : "↓"}
|
||||||
// Calculate AOV trends
|
{Math.abs(pct).toFixed(1)}%
|
||||||
const currentAOV = currentOrders ? currentRevenue / currentOrders : avgOrderValue;
|
</span>
|
||||||
const aovTrend = currentAOV >= prevAvgOrderValue ? "up" : "down";
|
);
|
||||||
const aovDiff = Math.abs(currentAOV - prevAvgOrderValue);
|
|
||||||
const aovPercentage = (aovDiff / prevAvgOrderValue) * 100;
|
|
||||||
|
|
||||||
// Convert trend direction to numeric value for DashboardStatCard
|
|
||||||
const getNumericTrend = (trendDir, percentage) => {
|
|
||||||
if (projectionLoading && periodProgress < 100) return undefined;
|
|
||||||
if (!isFinite(percentage)) return undefined;
|
|
||||||
return trendDir === "up" ? percentage : -percentage;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 dashboard-stagger w-full">
|
<div className="text-xs text-[#7c7870]">
|
||||||
<DashboardStatCard
|
Revenue{" "}
|
||||||
title="Total Revenue"
|
<span className="font-bold tabular-nums text-[#2b2925]">
|
||||||
value={formatCurrency(totalRevenue, false)}
|
{formatCurrency(totalRevenue, false)}
|
||||||
subtitle={
|
</span>
|
||||||
periodProgress < 100
|
<Delta pct={revPct} />
|
||||||
? `Projected: ${formatCurrency(projection?.projectedRevenue || totalRevenue, false)}`
|
<span className="mx-1.5 text-[#d8d4cd]">·</span>
|
||||||
: `Previous: ${formatCurrency(prevRevenue, false)}`
|
Orders{" "}
|
||||||
}
|
<span className="font-bold tabular-nums text-[#2b2925]">
|
||||||
trend={getNumericTrend(revenueTrend, revenuePercentage) !== undefined
|
{totalOrders.toLocaleString()}
|
||||||
? { value: getNumericTrend(revenueTrend, revenuePercentage) }
|
</span>
|
||||||
: undefined}
|
<Delta pct={ordPct} />
|
||||||
tooltip="Total revenue for the selected period"
|
<span className="mx-1.5 text-[#d8d4cd]">·</span>
|
||||||
size="compact"
|
AOV{" "}
|
||||||
/>
|
<span className="font-bold tabular-nums text-[#2b2925]">
|
||||||
|
{formatCurrency(avgOrderValue)}
|
||||||
<DashboardStatCard
|
</span>
|
||||||
title="Total Orders"
|
|
||||||
value={totalOrders.toLocaleString()}
|
|
||||||
subtitle={
|
|
||||||
periodProgress < 100
|
|
||||||
? `Projected: ${(projection?.projectedOrders || totalOrders).toLocaleString()}`
|
|
||||||
: `Previous: ${prevOrders.toLocaleString()}`
|
|
||||||
}
|
|
||||||
trend={getNumericTrend(ordersTrend, ordersPercentage) !== undefined
|
|
||||||
? { value: getNumericTrend(ordersTrend, ordersPercentage) }
|
|
||||||
: undefined}
|
|
||||||
tooltip="Total number of orders for the selected period"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DashboardStatCard
|
|
||||||
title="AOV"
|
|
||||||
value={formatCurrency(avgOrderValue)}
|
|
||||||
subtitle={
|
|
||||||
periodProgress < 100
|
|
||||||
? `Projected: ${formatCurrency(currentAOV)}`
|
|
||||||
: `Previous: ${formatCurrency(prevAvgOrderValue)}`
|
|
||||||
}
|
|
||||||
trend={getNumericTrend(aovTrend, aovPercentage) !== undefined
|
|
||||||
? { value: getNumericTrend(aovTrend, aovPercentage) }
|
|
||||||
: undefined}
|
|
||||||
tooltip="Average value per order for the selected period"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Best Day"
|
|
||||||
value={formatCurrency(bestDay?.revenue || 0, false)}
|
|
||||||
subtitle={
|
|
||||||
bestDay?.timestamp
|
|
||||||
? `${new Date(bestDay.timestamp).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})} - ${bestDay.orders} orders`
|
|
||||||
: "No data"
|
|
||||||
}
|
|
||||||
tooltip="Day with highest revenue in the selected period"
|
|
||||||
size="compact"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -332,14 +284,10 @@ SummaryStats.displayName = "SummaryStats";
|
|||||||
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
|
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
|
||||||
|
|
||||||
const SkeletonStats = () => (
|
const SkeletonStats = () => (
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 max-w-3xl">
|
<div className="h-4 w-64 animate-pulse rounded bg-[#f0eeea]" />
|
||||||
{[...Array(4)].map((_, i) => (
|
|
||||||
<DashboardStatCardSkeleton key={i} size="compact" hasIcon={false} hasSubtitle />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
const SalesChart = ({ timeRange = "last30days", title = "Sales" }) => {
|
||||||
const [data, setData] = useState([]);
|
const [data, setData] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
@@ -466,11 +414,15 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
const headerActions = !error ? (
|
const headerActions = !error ? (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" className="h-9">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 rounded-full border-[#e7e5e1] text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]"
|
||||||
|
>
|
||||||
Details
|
Details
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</DialogTrigger>
|
||||||
<DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col bg-card">
|
<DialogContent className="p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col rounded-[14px] border-[#e7e5e1] bg-white">
|
||||||
<DialogHeader className="flex-none">
|
<DialogHeader className="flex-none">
|
||||||
<DialogTitle className="text-foreground">
|
<DialogTitle className="text-foreground">
|
||||||
Daily Details
|
Daily Details
|
||||||
@@ -478,8 +430,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
<div className="flex items-center justify-center gap-2 pt-4">
|
<div className="flex items-center justify-center gap-2 pt-4">
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant={metrics.revenue ? "default" : "outline"}
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics.revenue ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setMetrics((prev) => ({
|
setMetrics((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -490,8 +443,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
Revenue
|
Revenue
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={metrics.orders ? "default" : "outline"}
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics.orders ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setMetrics((prev) => ({
|
setMetrics((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -503,9 +457,10 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={
|
||||||
metrics.movingAverage ? "default" : "outline"
|
metrics.movingAverage ? "secondary" : "ghost"
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setMetrics((prev) => ({
|
setMetrics((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -517,9 +472,10 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant={
|
variant={
|
||||||
metrics.avgOrderValue ? "default" : "outline"
|
metrics.avgOrderValue ? "secondary" : "ghost"
|
||||||
}
|
}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-7 text-xs"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setMetrics((prev) => ({
|
setMetrics((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -534,8 +490,9 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
<Separator orientation="vertical" className="h-6" />
|
<Separator orientation="vertical" className="h-6" />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={metrics.showPrevious ? "default" : "outline"}
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className={`h-7 rounded-full px-3 text-xs font-medium ${metrics.showPrevious ? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" : "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setMetrics((prev) => ({
|
setMetrics((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -661,11 +618,12 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
<Card className={`w-full ${CARD_STYLES.elevated}`}>
|
<Card className={`w-full ${CARD_STYLES.elevated}`}>
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title={title}
|
title={title}
|
||||||
|
size="large"
|
||||||
timeSelector={timeSelector}
|
timeSelector={timeSelector}
|
||||||
actions={headerActions}
|
actions={headerActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CardContent className="p-6 pt-0 space-y-4">
|
<CardContent className="p-4 pt-3 space-y-3">
|
||||||
{/* Show stats only if not in error state */}
|
{/* Show stats only if not in error state */}
|
||||||
{!error && (
|
{!error && (
|
||||||
loading ? (
|
loading ? (
|
||||||
@@ -679,81 +637,47 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Show metric toggles only if not in error state */}
|
{/* Metric toggle pills — the pill dot doubles as the series legend */}
|
||||||
{!error && (
|
{!error && (
|
||||||
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
<div className="flex flex-wrap gap-1">
|
<MetricPill
|
||||||
<Button
|
active={metrics.revenue}
|
||||||
variant={metrics.revenue ? "default" : "outline"}
|
color={CHART_COLORS.revenue}
|
||||||
size="sm"
|
onClick={() => setMetrics((prev) => ({ ...prev, revenue: !prev.revenue }))}
|
||||||
onClick={() =>
|
|
||||||
setMetrics((prev) => ({
|
|
||||||
...prev,
|
|
||||||
revenue: !prev.revenue,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Revenue
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={metrics.orders ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setMetrics((prev) => ({
|
|
||||||
...prev,
|
|
||||||
orders: !prev.orders,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Orders
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={metrics.movingAverage ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setMetrics((prev) => ({
|
|
||||||
...prev,
|
|
||||||
movingAverage: !prev.movingAverage,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
7-Day Avg
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant={metrics.avgOrderValue ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setMetrics((prev) => ({
|
|
||||||
...prev,
|
|
||||||
avgOrderValue: !prev.avgOrderValue,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
AOV
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator
|
|
||||||
orientation="vertical"
|
|
||||||
className="h-6 hidden sm:block"
|
|
||||||
/>
|
|
||||||
<Separator
|
|
||||||
orientation="horizontal"
|
|
||||||
className="sm:hidden w-20 my-2"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant={metrics.showPrevious ? "default" : "outline"}
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setMetrics((prev) => ({
|
|
||||||
...prev,
|
|
||||||
showPrevious: !prev.showPrevious,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Compare Prev Period
|
Revenue
|
||||||
</Button>
|
</MetricPill>
|
||||||
|
<MetricPill
|
||||||
|
active={metrics.orders}
|
||||||
|
color={CHART_COLORS.orders}
|
||||||
|
onClick={() => setMetrics((prev) => ({ ...prev, orders: !prev.orders }))}
|
||||||
|
>
|
||||||
|
Orders
|
||||||
|
</MetricPill>
|
||||||
|
<MetricPill
|
||||||
|
active={metrics.avgOrderValue}
|
||||||
|
color={CHART_COLORS.aov}
|
||||||
|
onClick={() => setMetrics((prev) => ({ ...prev, avgOrderValue: !prev.avgOrderValue }))}
|
||||||
|
>
|
||||||
|
AOV
|
||||||
|
</MetricPill>
|
||||||
|
<MetricPill
|
||||||
|
active={metrics.movingAverage}
|
||||||
|
color={CHART_COLORS.movingAverage}
|
||||||
|
dashed
|
||||||
|
onClick={() => setMetrics((prev) => ({ ...prev, movingAverage: !prev.movingAverage }))}
|
||||||
|
>
|
||||||
|
7-day avg
|
||||||
|
</MetricPill>
|
||||||
|
<span className="mx-1.5 h-4 w-px bg-[#eceae5]" />
|
||||||
|
<MetricPill
|
||||||
|
active={metrics.showPrevious}
|
||||||
|
color={CHART_COLORS.prevRevenue}
|
||||||
|
dashed
|
||||||
|
onClick={() => setMetrics((prev) => ({ ...prev, showPrevious: !prev.showPrevious }))}
|
||||||
|
>
|
||||||
|
vs previous
|
||||||
|
</MetricPill>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -774,46 +698,50 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales Overview" }) => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="h-[400px] mt-4 bg-card rounded-lg p-0 relative">
|
<div className="h-[340px] relative">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<LineChart
|
<LineChart
|
||||||
data={data}
|
data={data}
|
||||||
margin={{ top: 5, right: -30, left: -5, bottom: 5 }}
|
margin={{ top: 8, right: 0, left: -8, bottom: 0 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 4"
|
||||||
className="stroke-muted"
|
stroke="#efede9"
|
||||||
|
vertical={false}
|
||||||
/>
|
/>
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={formatXAxis}
|
tickFormatter={formatXAxis}
|
||||||
className="text-xs text-muted-foreground"
|
tick={{ fill: "#a09b92", fontSize: 11 }}
|
||||||
tick={{ fill: "currentColor" }}
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#eceae5" }}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="revenue"
|
yAxisId="revenue"
|
||||||
tickFormatter={(value) => formatCurrency(value, false)}
|
tickFormatter={(value) => formatCurrency(value, false)}
|
||||||
className="text-xs text-muted-foreground"
|
tick={{ fill: "#a09b92", fontSize: 11 }}
|
||||||
tick={{ fill: "currentColor" }}
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis
|
<YAxis
|
||||||
yAxisId="orders"
|
yAxisId="orders"
|
||||||
orientation="right"
|
orientation="right"
|
||||||
tickFormatter={(value) => value.toLocaleString()}
|
tickFormatter={(value) => value.toLocaleString()}
|
||||||
className="text-xs text-muted-foreground"
|
tick={{ fill: "#a09b92", fontSize: 11 }}
|
||||||
tick={{ fill: "currentColor" }}
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={<DashboardChartTooltip valueFormatter={salesValueFormatter} labelFormatter={salesLabelFormatter} />} />
|
<Tooltip content={<DashboardChartTooltip valueFormatter={salesValueFormatter} labelFormatter={salesLabelFormatter} />} />
|
||||||
<Legend />
|
|
||||||
<ReferenceLine
|
<ReferenceLine
|
||||||
y={averageRevenue}
|
y={averageRevenue}
|
||||||
yAxisId="revenue"
|
yAxisId="revenue"
|
||||||
stroke="currentColor"
|
stroke="#d8d4cd"
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 4"
|
||||||
label={{
|
label={{
|
||||||
value: `Avg Revenue: ${formatCurrency(averageRevenue)}`,
|
value: `avg ${formatCurrency(averageRevenue, false)}`,
|
||||||
fill: "currentColor",
|
fill: "#a09b92",
|
||||||
fontSize: 12,
|
fontSize: 10,
|
||||||
|
position: "insideBottomRight",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{metrics.revenue && (
|
{metrics.revenue && (
|
||||||
|
|||||||
@@ -28,8 +28,6 @@ import {
|
|||||||
// Import Tooltip from recharts with alias to avoid naming conflict
|
// Import Tooltip from recharts with alias to avoid naming conflict
|
||||||
import { Tooltip as RechartsTooltip } from "recharts";
|
import { Tooltip as RechartsTooltip } from "recharts";
|
||||||
import {
|
import {
|
||||||
DollarSign,
|
|
||||||
ShoppingCart,
|
|
||||||
Package,
|
Package,
|
||||||
Clock,
|
Clock,
|
||||||
Map,
|
Map,
|
||||||
@@ -39,11 +37,11 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
RefreshCcw,
|
RefreshCcw,
|
||||||
CircleDollarSign,
|
|
||||||
MapPin,
|
MapPin,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import { CARD_STYLES } from "@/lib/dashboard/designTokens";
|
import { CARD_STYLES, STUDIO_COLORS } from "@/lib/dashboard/designTokens";
|
||||||
|
import StudioStatTile from "@/components/dashboard/studio/StudioStatTile";
|
||||||
import {
|
import {
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
DashboardStatCardSkeleton,
|
DashboardStatCardSkeleton,
|
||||||
@@ -52,6 +50,7 @@ import {
|
|||||||
DashboardEmptyState,
|
DashboardEmptyState,
|
||||||
DashboardErrorState,
|
DashboardErrorState,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
|
QuietStat,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -90,7 +89,7 @@ const TimeSeriesChart = ({
|
|||||||
data,
|
data,
|
||||||
dataKey,
|
dataKey,
|
||||||
name,
|
name,
|
||||||
color = "hsl(var(--primary))",
|
color = "#e06a4e",
|
||||||
type = "line",
|
type = "line",
|
||||||
valueFormatter = (value) => value,
|
valueFormatter = (value) => value,
|
||||||
height = 400,
|
height = 400,
|
||||||
@@ -110,7 +109,7 @@ const TimeSeriesChart = ({
|
|||||||
data={data}
|
data={data}
|
||||||
margin={{ top: 10, right: 20, left: -20, bottom: 5 }}
|
margin={{ top: 10, right: 20, left: -20, bottom: 5 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<CartesianGrid strokeDasharray="3 4" stroke="#efede9" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(value) => DateTime.fromISO(value).toFormat("LLL d")}
|
tickFormatter={(value) => DateTime.fromISO(value).toFormat("LLL d")}
|
||||||
@@ -161,7 +160,7 @@ const TimeSeriesChart = ({
|
|||||||
|
|
||||||
const DetailDialog = ({ open, onOpenChange, title, children }) => (
|
const DetailDialog = ({ open, onOpenChange, title, children }) => (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto rounded-[14px] border-[#e7e5e1]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{title}</DialogTitle>
|
<DialogTitle>{title}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -196,7 +195,7 @@ const RevenueDetails = ({ data }) => {
|
|||||||
data={chartData}
|
data={chartData}
|
||||||
dataKey="revenue"
|
dataKey="revenue"
|
||||||
name="Revenue"
|
name="Revenue"
|
||||||
color="hsl(142.1 76.2% 36.3%)"
|
color="#e06a4e"
|
||||||
valueFormatter={(value) => formatCurrency(value)}
|
valueFormatter={(value) => formatCurrency(value)}
|
||||||
height={400}
|
height={400}
|
||||||
/>
|
/>
|
||||||
@@ -217,7 +216,7 @@ const OrdersDetails = ({ data }) => {
|
|||||||
data={data}
|
data={data}
|
||||||
dataKey="orders"
|
dataKey="orders"
|
||||||
name="Orders"
|
name="Orders"
|
||||||
color="hsl(221.2 83.2% 53.3%)"
|
color="#6b5fc7"
|
||||||
/>
|
/>
|
||||||
{data[0]?.hourlyOrders && (
|
{data[0]?.hourlyOrders && (
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
@@ -230,7 +229,7 @@ const OrdersDetails = ({ data }) => {
|
|||||||
dataKey="orders"
|
dataKey="orders"
|
||||||
name="Orders"
|
name="Orders"
|
||||||
type="bar"
|
type="bar"
|
||||||
color="hsl(221.2 83.2% 53.3%)"
|
color="#6b5fc7"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -252,7 +251,7 @@ const AverageOrderDetails = ({ data }) => {
|
|||||||
data={data}
|
data={data}
|
||||||
dataKey="averageOrderValue"
|
dataKey="averageOrderValue"
|
||||||
name="Average Order Value"
|
name="Average Order Value"
|
||||||
color="hsl(262.1 83.3% 57.8%)"
|
color="#a87a24"
|
||||||
valueFormatter={(value) => formatCurrency(value)}
|
valueFormatter={(value) => formatCurrency(value)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -293,7 +292,7 @@ const CancellationsDetails = ({ data }) => {
|
|||||||
data={timeSeriesData}
|
data={timeSeriesData}
|
||||||
dataKey="total"
|
dataKey="total"
|
||||||
name="Cancellation Amount"
|
name="Cancellation Amount"
|
||||||
color="hsl(0 84.2% 60.2%)"
|
color="#b3503f"
|
||||||
valueFormatter={(value) => formatCurrency(value)}
|
valueFormatter={(value) => formatCurrency(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,7 +304,7 @@ const CancellationsDetails = ({ data }) => {
|
|||||||
dataKey="count"
|
dataKey="count"
|
||||||
name="Cancellation Count"
|
name="Cancellation Count"
|
||||||
type="bar"
|
type="bar"
|
||||||
color="hsl(0 84.2% 60.2%)"
|
color="#b3503f"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -629,7 +628,7 @@ const PeakHourDetails = ({ data }) => {
|
|||||||
data={hourlyData}
|
data={hourlyData}
|
||||||
margin={{ top: 10, right: 30, left: 20, bottom: 5 }}
|
margin={{ top: 10, right: 30, left: 20, bottom: 5 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<CartesianGrid strokeDasharray="3 4" stroke="#efede9" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="timestamp"
|
dataKey="timestamp"
|
||||||
tickFormatter={(hour) => {
|
tickFormatter={(hour) => {
|
||||||
@@ -833,7 +832,7 @@ const OrderRangeDetails = ({ data }) => {
|
|||||||
data={timeSeriesData}
|
data={timeSeriesData}
|
||||||
dataKey="average"
|
dataKey="average"
|
||||||
name="Average Order Value"
|
name="Average Order Value"
|
||||||
color="hsl(142.1 76.2% 36.3%)"
|
color="#e06a4e"
|
||||||
valueFormatter={(value) => formatCurrency(value)}
|
valueFormatter={(value) => formatCurrency(value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -881,7 +880,7 @@ const OrderRangeDetails = ({ data }) => {
|
|||||||
data={formattedDistributionData}
|
data={formattedDistributionData}
|
||||||
margin={{ top: 10, right: 20, left: -20, bottom: 40 }}
|
margin={{ top: 10, right: 20, left: -20, bottom: 40 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
<CartesianGrid strokeDasharray="3 4" stroke="#efede9" vertical={false} />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="range"
|
dataKey="range"
|
||||||
angle={-45}
|
angle={-45}
|
||||||
@@ -976,6 +975,7 @@ const MemoizedCancellationsDetails = memo(CancellationsDetails);
|
|||||||
|
|
||||||
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
|
// Note: Using ChartSkeleton and TableSkeleton from @/components/dashboard/shared
|
||||||
|
|
||||||
|
// Quiet secondary stat — deemphasized strip item for the less-watched metrics
|
||||||
const StatCards = ({
|
const StatCards = ({
|
||||||
timeRange: initialTimeRange = "today",
|
timeRange: initialTimeRange = "today",
|
||||||
startDate,
|
startDate,
|
||||||
@@ -1268,14 +1268,18 @@ const StatCards = ({
|
|||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
const loadProjection = async () => {
|
const loadProjection = async () => {
|
||||||
if (!stats?.periodProgress || stats.periodProgress >= 100) return;
|
// The projection endpoint only serves these always-incomplete presets
|
||||||
|
// (it returns method:'none' for everything else), so key off timeRange
|
||||||
|
// instead of waiting for stats — this fires in parallel with the
|
||||||
|
// stats request rather than chained after it.
|
||||||
|
if (!["today", "thisWeek", "thisMonth"].includes(timeRange)) {
|
||||||
|
setProjection(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setProjectionLoading(true);
|
setProjectionLoading(true);
|
||||||
const params =
|
const response = await acotService.getProjection({ timeRange });
|
||||||
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
|
||||||
|
|
||||||
const response = await acotService.getProjection(params);
|
|
||||||
|
|
||||||
if (!isMounted) return;
|
if (!isMounted) return;
|
||||||
setProjection(response);
|
setProjection(response);
|
||||||
@@ -1292,7 +1296,7 @@ const StatCards = ({
|
|||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
};
|
};
|
||||||
}, [timeRange, startDate, endDate, stats?.periodProgress]);
|
}, [timeRange]);
|
||||||
|
|
||||||
// Auto-refresh for 'today' view
|
// Auto-refresh for 'today' view
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1316,6 +1320,89 @@ const StatCards = ({
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [timeRange]);
|
}, [timeRange]);
|
||||||
|
|
||||||
|
// 30-day daily series for the hero-tile sparklines. Context stays 30d
|
||||||
|
// regardless of the selected range — a one-point "today" series has no shape.
|
||||||
|
const [sparkRows, setSparkRows] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cached = getCacheData("last30days", "hero_sparklines");
|
||||||
|
if (cached) {
|
||||||
|
setSparkRows(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await acotService.getStatsDetails({
|
||||||
|
timeRange: "last30days",
|
||||||
|
metric: "revenue",
|
||||||
|
daily: true,
|
||||||
|
});
|
||||||
|
if (cancelled) return;
|
||||||
|
const rows = Array.isArray(response.stats) ? response.stats : [];
|
||||||
|
setCacheData("last30days", "hero_sparklines", rows);
|
||||||
|
setSparkRows(rows);
|
||||||
|
} catch {
|
||||||
|
// sparklines are optional garnish — tiles render fine without them
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [getCacheData, setCacheData]);
|
||||||
|
|
||||||
|
// Financials for the Gross Profit hero tile (selected range) and its
|
||||||
|
// 30-day sparkline. Both cached; the tile degrades to an em dash on failure.
|
||||||
|
const [profitData, setProfitData] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (timeRange === "custom") {
|
||||||
|
setProfitData(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cached = getCacheData(timeRange, "hero_financials");
|
||||||
|
if (cached) {
|
||||||
|
setProfitData(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await acotService.getFinancials({ timeRange });
|
||||||
|
if (cancelled) return;
|
||||||
|
setCacheData(timeRange, "hero_financials", response);
|
||||||
|
setProfitData(response);
|
||||||
|
} catch {
|
||||||
|
// tile shows an em dash without financials
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [timeRange, getCacheData, setCacheData]);
|
||||||
|
|
||||||
|
const [profitSparkRows, setProfitSparkRows] = useState(null);
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const cached = getCacheData("last30days", "hero_profit_spark");
|
||||||
|
if (cached) {
|
||||||
|
setProfitSparkRows(cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await acotService.getFinancials({ timeRange: "last30days" });
|
||||||
|
if (cancelled) return;
|
||||||
|
const rows = Array.isArray(response?.trend) ? response.trend : [];
|
||||||
|
setCacheData("last30days", "hero_profit_spark", rows);
|
||||||
|
setProfitSparkRows(rows);
|
||||||
|
} catch {
|
||||||
|
// sparkline is optional garnish
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [getCacheData, setCacheData]);
|
||||||
|
|
||||||
// Fetch detail data when a metric is selected (if not already cached)
|
// Fetch detail data when a metric is selected (if not already cached)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedMetric) return;
|
if (!selectedMetric) return;
|
||||||
@@ -1414,69 +1501,42 @@ const StatCards = ({
|
|||||||
|
|
||||||
if (loading && !stats) {
|
if (loading && !stats) {
|
||||||
return (
|
return (
|
||||||
<Card className={`w-full ${CARD_STYLES.base}`}>
|
<div className="w-full">
|
||||||
<CardHeader className="p-6 pb-2">
|
<div className="mb-2 flex items-center justify-between gap-3 px-0.5">
|
||||||
<div className="flex flex-col space-y-2">
|
<Skeleton className="h-4 w-28 rounded bg-[#f0eeea]" />
|
||||||
<div className="flex justify-between items-start">
|
<Skeleton className="h-8 w-24 rounded-full bg-[#f0eeea]" />
|
||||||
<div>
|
</div>
|
||||||
<CardTitle className="text-xl font-semibold text-foreground">
|
<div className="grid grid-cols-2 xl:grid-cols-[1.35fr_1fr_1fr_1fr] gap-2.5">
|
||||||
{title}
|
{[...Array(4)].map((_, i) => (
|
||||||
</CardTitle>
|
<div
|
||||||
{description && (
|
key={i}
|
||||||
<CardDescription className="mt-1 text-muted-foreground">
|
className="h-[132px] animate-pulse rounded-[14px]"
|
||||||
{description}
|
style={{ background: ["#fbe7de", "#ddf0ea", "#faf0d7", "#eae6f6"][i] }}
|
||||||
</CardDescription>
|
/>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="mt-2.5 grid grid-cols-2 gap-px overflow-hidden rounded-[14px] border border-[#e7e5e1] bg-[#f0eeea] sm:grid-cols-3 lg:grid-cols-6">
|
||||||
<Skeleton className="h-4 w-32 bg-muted rounded-sm" />
|
{[...Array(6)].map((_, i) => (
|
||||||
<Skeleton className="h-9 w-[130px] bg-muted rounded-md" />
|
<div key={i} className="bg-white px-3.5 py-2.5">
|
||||||
</div>
|
<div className="h-3 w-16 animate-pulse rounded bg-[#f0eeea]" />
|
||||||
|
<div className="mt-1.5 h-4 w-12 animate-pulse rounded bg-[#f5f3f0]" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="p-6 pt-0 space-y-4">
|
</div>
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-3 2xl:grid-cols-4 gap-2 md:gap-3">
|
|
||||||
{[...Array(12)].map((_, i) => (
|
|
||||||
<DashboardStatCardSkeleton key={i} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className={`w-full h-full ${CARD_STYLES.base}`}>
|
<div className="w-full">
|
||||||
<CardHeader className="p-6 pb-4">
|
<div className="mb-2 flex items-center justify-end px-0.5">
|
||||||
<div className="flex flex-col space-y-2">
|
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
|
||||||
<div className="flex justify-between items-start">
|
</div>
|
||||||
<div>
|
<div className={`${CARD_STYLES.base} p-4`}>
|
||||||
<CardTitle className="text-xl font-semibold text-foreground">
|
|
||||||
{title}
|
|
||||||
</CardTitle>
|
|
||||||
{description && (
|
|
||||||
<CardDescription className="mt-1 text-muted-foreground">
|
|
||||||
{description}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
{lastUpdate && !loading && (
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
Last updated: {lastUpdate.toFormat("hh:mm a")}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6 pt-0">
|
|
||||||
<DashboardErrorState error={`Failed to load stats: ${error}`} className="mx-0 my-0" />
|
<DashboardErrorState error={`Failed to load stats: ${error}`} className="mx-0 my-0" />
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1487,56 +1547,43 @@ const StatCards = ({
|
|||||||
const aovTrend = calculateAOVTrend();
|
const aovTrend = calculateAOVTrend();
|
||||||
const isSingleDay = ["today", "yesterday"].includes(timeRange);
|
const isSingleDay = ["today", "yesterday"].includes(timeRange);
|
||||||
|
|
||||||
|
const profitTotals = profitData?.totals;
|
||||||
|
const prevProfitTotal = profitData?.previousTotals?.profit;
|
||||||
|
// Mid-period, compare a PROJECTED end-of-period profit against the previous
|
||||||
|
// period (same as revenue/orders) — raw partial-day profit vs a full prior
|
||||||
|
// day would read as a huge drop until midnight. Projection assumes the
|
||||||
|
// current margin holds: profit × (projectedRevenue / revenue so far).
|
||||||
|
const profitTrendPct = (() => {
|
||||||
|
if (!profitTotals || !(prevProfitTotal > 0)) return null;
|
||||||
|
if (stats?.periodProgress < 100) {
|
||||||
|
const projRevForTrend = projection?.projectedRevenue || stats.projectedRevenue;
|
||||||
|
if (!(stats.revenue > 0) || !projRevForTrend) return null;
|
||||||
|
const projectedProfit = profitTotals.profit * (projRevForTrend / stats.revenue);
|
||||||
|
return ((projectedProfit - prevProfitTotal) / prevProfitTotal) * 100;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
profitData?.comparison?.profit?.percentage ??
|
||||||
|
((profitTotals.profit - prevProfitTotal) / prevProfitTotal) * 100
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
const sparkProfit = profitSparkRows?.map((d) => Number(d.profit) || 0);
|
||||||
|
|
||||||
|
const sparkRevenue = sparkRows?.map((d) => Number(d.revenue) || 0);
|
||||||
|
const sparkOrders = sparkRows?.map((d) => Number(d.orders) || 0);
|
||||||
|
const sparkAov = sparkRows?.map((d) =>
|
||||||
|
Number(d.orders) > 0 ? (Number(d.revenue) || 0) / Number(d.orders) : 0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`w-full ${CARD_STYLES.base}`}>
|
<div className="w-full">
|
||||||
<CardHeader className="p-6">
|
{/* Slim control row — no card chrome, the tiles speak for themselves */}
|
||||||
<div className="flex flex-col space-y-2">
|
<div className="mb-2 flex items-center justify-end px-0.5">
|
||||||
<div className="flex justify-between items-start">
|
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
|
||||||
<div>
|
</div>
|
||||||
<CardTitle className="text-xl font-semibold text-gray-900 dark:text-gray-100 flex items-baseline gap-1">
|
<div>
|
||||||
{title}
|
{/* Hero tiles — Sorbet Studio pastel identities with 30d sparklines */}
|
||||||
{lastUpdate && !loading && (
|
<div className="grid grid-cols-2 xl:grid-cols-[1.35fr_1fr_1fr_1fr] gap-2.5 dashboard-stagger">
|
||||||
<div className="text-xs text-muted-foreground font-medium">
|
<StudioStatTile
|
||||||
Last updated {lastUpdate.toFormat("h:mm a")}
|
|
||||||
{projection?.confidence > 0 && !projectionLoading && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={300}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<span className="ml-1 text-muted-foreground">
|
|
||||||
(
|
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
color: `rgb(${255 * (1 - projection.confidence)}, ${
|
|
||||||
255 * projection.confidence
|
|
||||||
}, 0)`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Math.round(projection.confidence * 100)}%
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
</span>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent className="max-w-[250px]">
|
|
||||||
<p>Confidence level of revenue projection based on historical data patterns</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardTitle>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<BusinessRangeSelect value={timeRange} onValueChange={setTimeRange} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="p-6 pt-0">
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-3 2xl:grid-cols-4 gap-2 md:gap-3 dashboard-stagger">
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Revenue"
|
title="Revenue"
|
||||||
value={formatCurrency(stats?.revenue || 0)}
|
value={formatCurrency(stats?.revenue || 0)}
|
||||||
subtitle={
|
subtitle={
|
||||||
@@ -1561,13 +1608,14 @@ const StatCards = ({
|
|||||||
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value, moreIsBetter: true }
|
? { value: revenueTrend.trend === "up" ? revenueTrend.value : -revenueTrend.value, moreIsBetter: true }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={DollarSign}
|
tone="peach"
|
||||||
iconColor="green"
|
featured
|
||||||
|
spark={sparkRevenue}
|
||||||
onClick={() => setSelectedMetric("revenue")}
|
onClick={() => setSelectedMetric("revenue")}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardStatCard
|
<StudioStatTile
|
||||||
title="Orders"
|
title="Orders"
|
||||||
value={stats?.orderCount}
|
value={stats?.orderCount}
|
||||||
subtitle={`${stats?.itemCount} total items`}
|
subtitle={`${stats?.itemCount} total items`}
|
||||||
@@ -1578,150 +1626,102 @@ const StatCards = ({
|
|||||||
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value, moreIsBetter: true }
|
? { value: orderTrend.trend === "up" ? orderTrend.value : -orderTrend.value, moreIsBetter: true }
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
icon={ShoppingCart}
|
tone="mint"
|
||||||
iconColor="blue"
|
spark={sparkOrders}
|
||||||
onClick={() => setSelectedMetric("orders")}
|
onClick={() => setSelectedMetric("orders")}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardStatCard
|
<StudioStatTile
|
||||||
title="AOV"
|
title="AOV"
|
||||||
value={stats?.averageOrderValue?.toFixed(2)}
|
value={stats?.averageOrderValue?.toFixed(2)}
|
||||||
valuePrefix="$"
|
valuePrefix="$"
|
||||||
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
subtitle={`${stats?.averageItemsPerOrder?.toFixed(1)} items per order`}
|
||||||
trend={aovTrend?.value ? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value, moreIsBetter: true } : undefined}
|
trend={aovTrend?.value ? { value: aovTrend.trend === "up" ? aovTrend.value : -aovTrend.value, moreIsBetter: true } : undefined}
|
||||||
icon={CircleDollarSign}
|
tone="lemon"
|
||||||
iconColor="purple"
|
spark={sparkAov}
|
||||||
onClick={() => setSelectedMetric("average_order")}
|
onClick={() => setSelectedMetric("average_order")}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardStatCard
|
<StudioStatTile
|
||||||
title="Brands"
|
title="Gross Profit"
|
||||||
value={stats?.brands?.total || 0}
|
value={profitTotals ? formatCurrency(profitTotals.profit) : "—"}
|
||||||
subtitle={`${stats?.categories?.total || 0} categories`}
|
subtitle={
|
||||||
icon={Tags}
|
profitTotals && Number.isFinite(profitTotals.margin)
|
||||||
iconColor="indigo"
|
? `${profitTotals.margin.toFixed(1)}% margin`
|
||||||
onClick={() => setSelectedMetric("brands_categories")}
|
: undefined
|
||||||
|
}
|
||||||
|
trend={
|
||||||
|
projectionLoading && stats?.periodProgress < 100
|
||||||
|
? undefined
|
||||||
|
: profitTrendPct != null && isFinite(profitTrendPct)
|
||||||
|
? { value: profitTrendPct, moreIsBetter: true }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
tone="lilac"
|
||||||
|
spark={sparkProfit}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DashboardStatCard
|
{/* Quiet strip — the less-watched stats, deemphasized but still tappable.
|
||||||
title="Shipped Orders"
|
(Brands and Order Range were retired in the Studio redesign.) */}
|
||||||
value={stats?.shipping?.shippedCount || 0}
|
<div className="mt-2.5 grid grid-cols-2 gap-px overflow-hidden rounded-[14px] border border-[#e7e5e1] bg-[#f0eeea] sm:grid-cols-3 lg:grid-cols-6">
|
||||||
subtitle={`${stats?.shipping?.locations?.total || 0} locations`}
|
<QuietStat
|
||||||
icon={Package}
|
label="Pre-orders"
|
||||||
iconColor="teal"
|
value={`${
|
||||||
onClick={() => setSelectedMetric("shipping")}
|
|
||||||
loading={loading || !stats}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Pre-Orders"
|
|
||||||
value={
|
|
||||||
stats?.orderCount > 0
|
stats?.orderCount > 0
|
||||||
? ((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100).toFixed(1)
|
? ((stats?.orderTypes?.preOrders?.count / stats?.orderCount) * 100).toFixed(1)
|
||||||
: "0"
|
: "0"
|
||||||
}
|
}%`}
|
||||||
valueSuffix="%"
|
sub={`${stats?.orderTypes?.preOrders?.count || 0} orders`}
|
||||||
subtitle={`${stats?.orderTypes?.preOrders?.count || 0} orders`}
|
|
||||||
icon={Clock}
|
|
||||||
iconColor="yellow"
|
|
||||||
onClick={() => setSelectedMetric("pre_orders")}
|
onClick={() => setSelectedMetric("pre_orders")}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
<QuietStat
|
||||||
<DashboardStatCard
|
label="Local pickup"
|
||||||
title="Local Pickup"
|
value={`${
|
||||||
value={
|
|
||||||
stats?.orderCount > 0
|
stats?.orderCount > 0
|
||||||
? ((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100).toFixed(1)
|
? ((stats?.orderTypes?.localPickup?.count / stats?.orderCount) * 100).toFixed(1)
|
||||||
: "0"
|
: "0"
|
||||||
}
|
}%`}
|
||||||
valueSuffix="%"
|
sub={`${stats?.orderTypes?.localPickup?.count || 0} orders`}
|
||||||
subtitle={`${stats?.orderTypes?.localPickup?.count || 0} orders`}
|
|
||||||
icon={Map}
|
|
||||||
iconColor="cyan"
|
|
||||||
onClick={() => setSelectedMetric("local_pickup")}
|
onClick={() => setSelectedMetric("local_pickup")}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
<QuietStat
|
||||||
<DashboardStatCard
|
label="On hold"
|
||||||
title="On Hold"
|
value={`${
|
||||||
value={
|
|
||||||
stats?.orderCount > 0
|
stats?.orderCount > 0
|
||||||
? ((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100).toFixed(1)
|
? ((stats?.orderTypes?.heldItems?.count / stats?.orderCount) * 100).toFixed(1)
|
||||||
: "0"
|
: "0"
|
||||||
}
|
}%`}
|
||||||
valueSuffix="%"
|
sub={`${stats?.orderTypes?.heldItems?.count || 0} orders`}
|
||||||
subtitle={`${stats?.orderTypes?.heldItems?.count || 0} orders`}
|
|
||||||
icon={AlertCircle}
|
|
||||||
iconColor="red"
|
|
||||||
onClick={() => setSelectedMetric("on_hold")}
|
onClick={() => setSelectedMetric("on_hold")}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
<QuietStat
|
||||||
{isSingleDay ? (
|
label="Shipped orders"
|
||||||
<DashboardStatCard
|
value={(stats?.shipping?.shippedCount || 0).toLocaleString()}
|
||||||
title="Peak Hour"
|
sub={`${stats?.shipping?.locations?.total || 0} locations`}
|
||||||
value={stats?.peakOrderHour?.displayHour || "N/A"}
|
onClick={() => setSelectedMetric("shipping")}
|
||||||
subtitle={stats?.peakOrderHour ? `${stats?.peakOrderHour.count} orders` : undefined}
|
loading={loading || !stats}
|
||||||
icon={Clock}
|
/>
|
||||||
iconColor="pink"
|
<QuietStat
|
||||||
onClick={() => setSelectedMetric("peak_hour")}
|
label="Refunds"
|
||||||
loading={loading || !stats}
|
value={`$${stats?.refunds?.total?.toFixed(2) || "0.00"}`}
|
||||||
/>
|
sub={`${stats?.refunds?.count || 0} orders`}
|
||||||
) : (
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Best Revenue Day"
|
|
||||||
value={stats?.bestRevenueDay?.amount?.toFixed(2) || "N/A"}
|
|
||||||
valuePrefix="$"
|
|
||||||
subtitle={
|
|
||||||
stats?.bestRevenueDay?.displayDate
|
|
||||||
? new Date(stats?.bestRevenueDay.displayDate).toLocaleDateString("en-US", {
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
})
|
|
||||||
: "N/A"
|
|
||||||
}
|
|
||||||
icon={TrendingUp}
|
|
||||||
iconColor="emerald"
|
|
||||||
loading={loading || !stats}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Refunds"
|
|
||||||
value={stats?.refunds?.total?.toFixed(2) || "0.00"}
|
|
||||||
valuePrefix="$"
|
|
||||||
subtitle={`${stats?.refunds?.count || 0} refund orders`}
|
|
||||||
icon={RefreshCcw}
|
|
||||||
iconColor="orange"
|
|
||||||
onClick={() => setSelectedMetric("refunds")}
|
onClick={() => setSelectedMetric("refunds")}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
<QuietStat
|
||||||
<DashboardStatCard
|
label="Cancellations"
|
||||||
title="Cancellations"
|
value={`$${stats?.canceledOrders?.total?.toFixed(2) || "0.00"}`}
|
||||||
value={stats?.canceledOrders?.total?.toFixed(2) || "0.00"}
|
sub={`${stats?.canceledOrders?.count || 0} orders`}
|
||||||
valuePrefix="$"
|
|
||||||
subtitle={`${stats?.canceledOrders?.count || 0} canceled orders`}
|
|
||||||
icon={XCircle}
|
|
||||||
iconColor="rose"
|
|
||||||
onClick={() => setSelectedMetric("cancellations")}
|
onClick={() => setSelectedMetric("cancellations")}
|
||||||
loading={loading || !stats}
|
loading={loading || !stats}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardStatCard
|
|
||||||
title="Order Range"
|
|
||||||
value={stats?.orderValueRange?.largest?.toFixed(2) || "0.00"}
|
|
||||||
valuePrefix="$"
|
|
||||||
subtitle={`Min: $${stats?.orderValueRange?.smallest?.toFixed(2) || "0.00"}`}
|
|
||||||
icon={TrendingUp}
|
|
||||||
iconColor="violet"
|
|
||||||
onClick={() => setSelectedMetric("order_range")}
|
|
||||||
loading={loading || !stats}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DetailDialog
|
<DetailDialog
|
||||||
@@ -1738,8 +1738,8 @@ const StatCards = ({
|
|||||||
>
|
>
|
||||||
{getDetailComponent()}
|
{getDetailComponent()}
|
||||||
</DetailDialog>
|
</DetailDialog>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -42,13 +42,13 @@ const FORM_NAMES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const ResponseFeed = ({ responses, title, renderSummary }) => (
|
const ResponseFeed = ({ responses, title, renderSummary }) => (
|
||||||
<Card>
|
<Card className={`h-full ${CARD_STYLES.subtle} shadow-none`}>
|
||||||
<DashboardSectionHeader title={title} compact />
|
<DashboardSectionHeader title={title} compact accent={false} />
|
||||||
<CardContent>
|
<CardContent className="p-0">
|
||||||
<ScrollArea className="h-[400px]">
|
<ScrollArea className="h-[400px]">
|
||||||
<div className="divide-y divide-border/50">
|
<div className="divide-y divide-[#f5f3f0]">
|
||||||
{responses.items.map((response) => (
|
{responses.items.map((response) => (
|
||||||
<div key={response.token} className="p-4">
|
<div key={response.token} className="p-3 transition-colors hover:bg-[#faf9f7]">
|
||||||
{renderSummary(response)}
|
{renderSummary(response)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -73,12 +73,12 @@ const ProductRelevanceFeed = ({ responses }) => (
|
|||||||
{response.hidden?.email ? (
|
{response.hidden?.email ? (
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
|
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
|
||||||
className="text-sm font-medium text-foreground hover:underline"
|
className="text-[13px] font-semibold text-[#2b2925] hover:underline"
|
||||||
>
|
>
|
||||||
{response.hidden?.name || "Anonymous"}
|
{response.hidden?.name || "Anonymous"}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-[13px] font-semibold text-[#2b2925]">
|
||||||
{response.hidden?.name || "Anonymous"}
|
{response.hidden?.name || "Anonymous"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -87,14 +87,14 @@ const ProductRelevanceFeed = ({ responses }) => (
|
|||||||
</DashboardBadge>
|
</DashboardBadge>
|
||||||
</div>
|
</div>
|
||||||
<time
|
<time
|
||||||
className="text-xs text-muted-foreground"
|
className="text-[11px] text-[#a09b92]"
|
||||||
dateTime={response.submitted_at}
|
dateTime={response.submitted_at}
|
||||||
>
|
>
|
||||||
{format(new Date(response.submitted_at), "MMM d")}
|
{format(new Date(response.submitted_at), "MMM d")}
|
||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
{textAnswer && (
|
{textAnswer && (
|
||||||
<div className="text-sm text-muted-foreground">"{textAnswer}"</div>
|
<div className="text-[11.5px] text-[#7c7870]">"{textAnswer}"</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -122,12 +122,12 @@ const WinbackFeed = ({ responses }) => (
|
|||||||
{response.hidden?.email ? (
|
{response.hidden?.email ? (
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
|
href={`https://backend.acherryontop.com/search/?search_for=customers&search=${response.hidden.email}`}
|
||||||
className="text-sm font-medium text-foreground hover:underline"
|
className="text-[13px] font-semibold text-[#2b2925] hover:underline"
|
||||||
>
|
>
|
||||||
{response.hidden?.name || "Anonymous"}
|
{response.hidden?.name || "Anonymous"}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm font-medium text-foreground">
|
<span className="text-[13px] font-semibold text-[#2b2925]">
|
||||||
{response.hidden?.name || "Anonymous"}
|
{response.hidden?.name || "Anonymous"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -150,7 +150,7 @@ const WinbackFeed = ({ responses }) => (
|
|||||||
</DashboardBadge>
|
</DashboardBadge>
|
||||||
</div>
|
</div>
|
||||||
<time
|
<time
|
||||||
className="text-xs text-muted-foreground"
|
className="text-[11px] text-[#a09b92]"
|
||||||
dateTime={response.submitted_at}
|
dateTime={response.submitted_at}
|
||||||
>
|
>
|
||||||
{format(new Date(response.submitted_at), "MMM d")}
|
{format(new Date(response.submitted_at), "MMM d")}
|
||||||
@@ -169,7 +169,7 @@ const WinbackFeed = ({ responses }) => (
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{feedbackAnswer?.text && (
|
{feedbackAnswer?.text && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-[11.5px] text-[#7c7870]">
|
||||||
{feedbackAnswer.text}
|
{feedbackAnswer.text}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -368,56 +368,51 @@ const TypeformDashboard = () => {
|
|||||||
<Card className={CARD_STYLES.base}>
|
<Card className={CARD_STYLES.base}>
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="Customer Surveys"
|
title="Customer Surveys"
|
||||||
lastUpdated={newestResponse ? new Date(newestResponse) : null}
|
size="large"
|
||||||
lastUpdatedFormat={(date) => `Newest response: ${format(date, "MMM d, h:mm a")}`}
|
|
||||||
className="pb-0"
|
|
||||||
/>
|
/>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="p-4 pt-3 space-y-3">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<ChartSkeleton height="md" withCard={false} />
|
<ChartSkeleton height="md" withCard={false} />
|
||||||
<TableSkeleton rows={5} columns={3} />
|
<TableSkeleton rows={5} columns={3} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
<Card className="bg-card">
|
<Card className="rounded-[12px] border-[#efede9] shadow-none">
|
||||||
<CardHeader className="p-6">
|
<CardHeader className="p-4 pb-3">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between gap-2">
|
||||||
<CardTitle className="text-lg font-semibold">
|
<CardTitle className="text-[12.5px] font-semibold tracking-tight text-[#2b2925]">
|
||||||
How likely are you to place another order with us?
|
How likely are you to place another order with us?
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<span
|
<span
|
||||||
className={`text-2xl font-bold ${
|
className={`text-lg font-bold tabular-nums ${
|
||||||
metrics.winback.averageRating <= 1
|
metrics.winback.averageRating <= 1
|
||||||
? "text-red-600 dark:text-red-500"
|
? "text-[#b3503f]"
|
||||||
: metrics.winback.averageRating <= 2
|
: metrics.winback.averageRating <= 2
|
||||||
? "text-orange-600 dark:text-orange-500"
|
? "text-[#c9973d]"
|
||||||
: metrics.winback.averageRating <= 3
|
: metrics.winback.averageRating <= 3
|
||||||
? "text-yellow-600 dark:text-yellow-500"
|
? "text-[#b39b2e]"
|
||||||
: metrics.winback.averageRating <= 4
|
: metrics.winback.averageRating <= 4
|
||||||
? "text-lime-600 dark:text-lime-500"
|
? "text-[#7fa653]"
|
||||||
: "text-green-600 dark:text-green-500"
|
: "text-[#2e8f5b]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{metrics.winback.averageRating}
|
{metrics.winback.averageRating}
|
||||||
<span className="text-base font-normal text-muted-foreground">
|
<span className="text-xs font-normal text-muted-foreground">
|
||||||
/5 avg
|
/5 avg
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0">
|
||||||
<div className="h-[200px]">
|
<div className="h-[132px]">
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
<BarChart
|
<BarChart
|
||||||
data={likelihoodCounts}
|
data={likelihoodCounts}
|
||||||
margin={{ top: 0, right: 10, left: -20, bottom: -25 }}
|
margin={{ top: 4, right: 0, left: 0, bottom: -25 }}
|
||||||
>
|
>
|
||||||
<CartesianGrid
|
<CartesianGrid strokeDasharray="3 4" stroke="#efede9" vertical={false} />
|
||||||
strokeDasharray="3 3"
|
|
||||||
className="stroke-muted"
|
|
||||||
/>
|
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="rating"
|
dataKey="rating"
|
||||||
tickFormatter={(value) => {
|
tickFormatter={(value) => {
|
||||||
@@ -430,9 +425,16 @@ const TypeformDashboard = () => {
|
|||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
interval={0}
|
interval={0}
|
||||||
height={50}
|
height={50}
|
||||||
className="text-muted-foreground text-xs md:text-sm"
|
tick={{ fill: "#a09b92", fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={{ stroke: "#eceae5" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
width={36}
|
||||||
|
tick={{ fill: "#a09b92", fontSize: 11 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
/>
|
/>
|
||||||
<YAxis className="text-muted-foreground text-xs md:text-sm" />
|
|
||||||
<Tooltip
|
<Tooltip
|
||||||
content={
|
content={
|
||||||
<SimpleTooltip
|
<SimpleTooltip
|
||||||
@@ -441,20 +443,20 @@ const TypeformDashboard = () => {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Bar dataKey="count">
|
<Bar dataKey="count" radius={[6, 6, 0, 0]}>
|
||||||
{likelihoodCounts.map((_, index) => (
|
{likelihoodCounts.map((_, index) => (
|
||||||
<Cell
|
<Cell
|
||||||
key={`cell-${index}`}
|
key={`cell-${index}`}
|
||||||
fill={
|
fill={
|
||||||
index === 0
|
index === 0
|
||||||
? "#ef4444" // red
|
? "#b3503f" // red
|
||||||
: index === 1
|
: index === 1
|
||||||
? "#f97316" // orange
|
? "#c9973d" // orange
|
||||||
: index === 2
|
: index === 2
|
||||||
? "#eab308" // yellow
|
? "#d4b445" // yellow
|
||||||
: index === 3
|
: index === 3
|
||||||
? "#84cc16" // lime
|
? "#7fa653" // lime
|
||||||
: "#10b981" // green
|
: "#2e8f5b" // green
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -464,98 +466,40 @@ const TypeformDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<Card className="bg-card">
|
<Card className="rounded-[12px] border-[#efede9] shadow-none">
|
||||||
<CardHeader className="p-6">
|
<CardHeader className="p-4 pb-3">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<CardTitle className="text-[12.5px] font-semibold tracking-tight text-[#2b2925]">
|
||||||
<CardTitle className="text-lg font-semibold">
|
Were the suggested products in this email relevant to you?
|
||||||
Were the suggested products in this email relevant to you?
|
</CardTitle>
|
||||||
</CardTitle>
|
|
||||||
<div className="flex flex-col items-end">
|
|
||||||
<span className="text-2xl font-bold text-green-600 dark:text-green-500">
|
|
||||||
{metrics.productRelevance.yesPercentage}% Relevant
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0">
|
||||||
<div className="h-[100px]">
|
{/* CSS proportion bar — guarantees matching rounded ends on
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
both segments (recharts stacked-bar radius rounds unreliably) */}
|
||||||
<BarChart
|
{(() => {
|
||||||
data={[
|
const yes = metrics.productRelevance.yesCount;
|
||||||
{
|
const no = metrics.productRelevance.noCount;
|
||||||
yes: metrics.productRelevance.yesCount,
|
const total = yes + no || 1;
|
||||||
no: metrics.productRelevance.noCount,
|
const yesPct = Math.round((yes / total) * 100);
|
||||||
total:
|
return (
|
||||||
metrics.productRelevance.yesCount +
|
<div className="flex h-[52px] w-full gap-1 overflow-hidden rounded-xl">
|
||||||
metrics.productRelevance.noCount,
|
<div
|
||||||
},
|
className="flex items-center justify-center rounded-l-xl bg-[#2e8f5b] text-sm font-bold text-white"
|
||||||
]}
|
style={{ width: `${(yes / total) * 100}%` }}
|
||||||
layout="vertical"
|
title={`Yes: ${yes.toLocaleString()} (${yesPct}%)`}
|
||||||
stackOffset="expand"
|
|
||||||
margin={{ top: 0, right: 0, left: -20, bottom: 0 }}
|
|
||||||
>
|
|
||||||
<XAxis type="number" hide domain={[0, 1]} />
|
|
||||||
<YAxis type="category" hide />
|
|
||||||
<Tooltip
|
|
||||||
cursor={false}
|
|
||||||
content={({ payload }) => {
|
|
||||||
if (payload && payload.length) {
|
|
||||||
const yesCount = payload[0].payload.yes;
|
|
||||||
const noCount = payload[0].payload.no;
|
|
||||||
const total = yesCount + noCount;
|
|
||||||
const yesPercent = Math.round((yesCount / total) * 100);
|
|
||||||
const noPercent = Math.round((noCount / total) * 100);
|
|
||||||
return (
|
|
||||||
<div className={TOOLTIP_STYLES.container}>
|
|
||||||
<div className={TOOLTIP_STYLES.content}>
|
|
||||||
<div className={TOOLTIP_STYLES.row}>
|
|
||||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
|
||||||
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: '#10b981' }} />
|
|
||||||
<span className={TOOLTIP_STYLES.name}>Yes</span>
|
|
||||||
</div>
|
|
||||||
<span className={TOOLTIP_STYLES.value}>{yesCount} ({yesPercent}%)</span>
|
|
||||||
</div>
|
|
||||||
<div className={TOOLTIP_STYLES.row}>
|
|
||||||
<div className={TOOLTIP_STYLES.rowLabel}>
|
|
||||||
<span className={TOOLTIP_STYLES.dot} style={{ backgroundColor: '#ef4444' }} />
|
|
||||||
<span className={TOOLTIP_STYLES.name}>No</span>
|
|
||||||
</div>
|
|
||||||
<span className={TOOLTIP_STYLES.value}>{noCount} ({noPercent}%)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="yes"
|
|
||||||
stackId="stack"
|
|
||||||
fill="#10b981"
|
|
||||||
radius={[0, 0, 0, 0]}
|
|
||||||
>
|
>
|
||||||
<text
|
{yesPct >= 12 && `${yesPct}%`}
|
||||||
x="50%"
|
</div>
|
||||||
y="50%"
|
<div
|
||||||
textAnchor="middle"
|
className="flex items-center justify-center rounded-r-xl bg-[#b3503f] text-sm font-bold text-white"
|
||||||
fill="#fff"
|
style={{ width: `${(no / total) * 100}%` }}
|
||||||
fontSize={14}
|
title={`No: ${no.toLocaleString()} (${100 - yesPct}%)`}
|
||||||
fontWeight="bold"
|
>
|
||||||
>
|
{100 - yesPct >= 12 && `${100 - yesPct}%`}
|
||||||
{metrics.productRelevance.yesPercentage}%
|
</div>
|
||||||
</text>
|
</div>
|
||||||
</Bar>
|
);
|
||||||
<Bar
|
})()}
|
||||||
dataKey="no"
|
<div className="mt-2 flex justify-between px-1 text-xs font-medium text-[#7c7870]">
|
||||||
stackId="stack"
|
|
||||||
fill="#ef4444"
|
|
||||||
radius={[0, 0, 0, 0]}
|
|
||||||
/>
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between mt-2 text-md font-semibold mx-1 text-muted-foreground">
|
|
||||||
<div>Yes: {metrics.productRelevance.yesCount}</div>
|
<div>Yes: {metrics.productRelevance.yesCount}</div>
|
||||||
<div>No: {metrics.productRelevance.noCount}</div>
|
<div>No: {metrics.productRelevance.noCount}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -563,11 +507,11 @@ const TypeformDashboard = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 lg:grid-cols-12 gap-4">
|
<div className="grid grid-cols-2 lg:grid-cols-12 gap-3">
|
||||||
<div className="col-span-4 lg:col-span-12 xl:col-span-4">
|
<div className="col-span-4 lg:col-span-12 xl:col-span-4">
|
||||||
<Card className="bg-card h-full">
|
<Card className="rounded-[12px] border-[#efede9] shadow-none h-full">
|
||||||
<DashboardSectionHeader title="Reasons for Not Ordering" compact />
|
<DashboardSectionHeader title="Reasons for Not Ordering" compact accent={false} />
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-3">
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
columns={reasonsColumns}
|
columns={reasonsColumns}
|
||||||
data={metrics?.winback?.reasons || []}
|
data={metrics?.winback?.reasons || []}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
TableSkeleton,
|
TableSkeleton,
|
||||||
ChartSkeleton,
|
ChartSkeleton,
|
||||||
TOOLTIP_STYLES,
|
TOOLTIP_STYLES,
|
||||||
|
PILL_TRIGGER_CLASS,
|
||||||
} from "@/components/dashboard/shared";
|
} from "@/components/dashboard/shared";
|
||||||
|
|
||||||
export const UserBehaviorDashboard = () => {
|
export const UserBehaviorDashboard = () => {
|
||||||
@@ -136,7 +137,19 @@ export const UserBehaviorDashboard = () => {
|
|||||||
{
|
{
|
||||||
key: "path",
|
key: "path",
|
||||||
header: "Page Path",
|
header: "Page Path",
|
||||||
render: (value) => <span className="font-medium text-foreground">{value}</span>,
|
render: (value) =>
|
||||||
|
typeof value === "string" && value.startsWith("/") ? (
|
||||||
|
<a
|
||||||
|
href={`https://www.acherryontop.com${value}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="font-medium text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium text-foreground">{value}</span>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "pageViews",
|
key: "pageViews",
|
||||||
@@ -163,28 +176,28 @@ export const UserBehaviorDashboard = () => {
|
|||||||
{
|
{
|
||||||
key: "source",
|
key: "source",
|
||||||
header: "Source",
|
header: "Source",
|
||||||
width: "w-[35%] min-w-[120px]",
|
width: "w-[40%]",
|
||||||
render: (value) => <span className="font-medium text-foreground break-words max-w-[160px]">{value}</span>,
|
render: (value) => <span className="font-medium text-foreground break-all">{value}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "sessions",
|
key: "sessions",
|
||||||
header: "Sessions",
|
header: "Sessions",
|
||||||
align: "right",
|
align: "right",
|
||||||
width: "w-[20%] min-w-[80px]",
|
width: "w-[19%] whitespace-nowrap",
|
||||||
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
|
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "conversions",
|
key: "conversions",
|
||||||
header: "Conv.",
|
header: "Conv.",
|
||||||
align: "right",
|
align: "right",
|
||||||
width: "w-[20%] min-w-[80px]",
|
width: "w-[17%] whitespace-nowrap",
|
||||||
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
|
render: (value) => <span className="text-muted-foreground whitespace-nowrap">{value.toLocaleString()}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "conversionRate",
|
key: "conversionRate",
|
||||||
header: "Conv. Rate",
|
header: "Conv. Rate",
|
||||||
align: "right",
|
align: "right",
|
||||||
width: "w-[25%] min-w-[80px]",
|
width: "w-[24%] whitespace-nowrap",
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<span className="text-muted-foreground whitespace-nowrap">
|
<span className="text-muted-foreground whitespace-nowrap">
|
||||||
{((row.conversions / row.sessions) * 100).toFixed(1)}%
|
{((row.conversions / row.sessions) * 100).toFixed(1)}%
|
||||||
@@ -195,30 +208,30 @@ export const UserBehaviorDashboard = () => {
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
<Card className={`${CARD_STYLES.base} flex h-full flex-col`}>
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="User Behavior Analysis"
|
title="User Behavior Analysis"
|
||||||
loading={true}
|
loading={true}
|
||||||
size="large"
|
size="large"
|
||||||
timeSelector={<div className="w-36" />}
|
timeSelector={<div className="w-36" />}
|
||||||
/>
|
/>
|
||||||
<CardContent className="p-6 pt-0">
|
<CardContent className="p-4 pt-3">
|
||||||
<Tabs defaultValue="pages" className="w-full">
|
<Tabs defaultValue="pages" className="w-full">
|
||||||
<TabsList className="mb-4">
|
<TabsList className="mb-3 h-8 w-fit self-start">
|
||||||
<TabsTrigger value="pages" disabled>Top Pages</TabsTrigger>
|
<TabsTrigger value="pages" disabled>Top Pages</TabsTrigger>
|
||||||
<TabsTrigger value="sources" disabled>Traffic Sources</TabsTrigger>
|
<TabsTrigger value="sources" disabled>Traffic Sources</TabsTrigger>
|
||||||
<TabsTrigger value="devices" disabled>Device Usage</TabsTrigger>
|
<TabsTrigger value="devices" disabled>Device Usage</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="pages" className="mt-4 space-y-2">
|
<TabsContent value="pages" className="mt-3 space-y-2">
|
||||||
<TableSkeleton rows={15} columns={4} />
|
<TableSkeleton rows={15} columns={4} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sources" className="mt-4 space-y-2">
|
<TabsContent value="sources" className="mt-3 space-y-2">
|
||||||
<TableSkeleton rows={12} columns={4} />
|
<TableSkeleton rows={12} columns={4} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="devices" className="mt-4 space-y-2">
|
<TabsContent value="devices" className="mt-3 space-y-2">
|
||||||
<ChartSkeleton type="pie" height="sm" withCard={false} />
|
<ChartSkeleton type="pie" height="sm" withCard={false} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -228,9 +241,9 @@ export const UserBehaviorDashboard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const COLORS = {
|
const COLORS = {
|
||||||
desktop: "#8b5cf6", // Purple
|
desktop: "#6b5fc7", // Purple
|
||||||
mobile: "#10b981", // Green
|
mobile: "#0f8f77", // Green
|
||||||
tablet: "#f59e0b", // Yellow
|
tablet: "#a87a24", // Yellow
|
||||||
};
|
};
|
||||||
|
|
||||||
const deviceData = data?.data?.pageData?.deviceData || [];
|
const deviceData = data?.data?.pageData?.deviceData || [];
|
||||||
@@ -270,13 +283,13 @@ export const UserBehaviorDashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`${CARD_STYLES.base} h-full`}>
|
<Card className={`${CARD_STYLES.base} flex h-full flex-col`}>
|
||||||
<DashboardSectionHeader
|
<DashboardSectionHeader
|
||||||
title="User Behavior Analysis"
|
title="User Behavior Analysis"
|
||||||
size="large"
|
size="large"
|
||||||
timeSelector={
|
timeSelector={
|
||||||
<Select value={timeRange} onValueChange={setTimeRange}>
|
<Select value={timeRange} onValueChange={setTimeRange}>
|
||||||
<SelectTrigger className="w-36 h-9">
|
<SelectTrigger className={PILL_TRIGGER_CLASS}>
|
||||||
<SelectValue>
|
<SelectValue>
|
||||||
{timeRange === "7" && "Last 7 days"}
|
{timeRange === "7" && "Last 7 days"}
|
||||||
{timeRange === "14" && "Last 14 days"}
|
{timeRange === "14" && "Last 14 days"}
|
||||||
@@ -293,67 +306,108 @@ export const UserBehaviorDashboard = () => {
|
|||||||
</Select>
|
</Select>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<CardContent className="p-6 pt-0">
|
<CardContent className="flex min-h-0 flex-1 flex-col p-4 pt-3">
|
||||||
<Tabs defaultValue="pages" className="w-full">
|
{/* Fixed-height tab body so the card doesn't grow/shrink per tab; the
|
||||||
<TabsList className="mb-4">
|
tables scroll internally instead. On xl the card fills the Analytics
|
||||||
|
panel beside it (absolute-fill in Dashboard.tsx). */}
|
||||||
|
<Tabs defaultValue="pages" className="flex min-h-0 w-full flex-1 flex-col">
|
||||||
|
<TabsList className="mb-3 h-8 w-fit shrink-0 self-start">
|
||||||
<TabsTrigger value="pages">Top Pages</TabsTrigger>
|
<TabsTrigger value="pages">Top Pages</TabsTrigger>
|
||||||
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
<TabsTrigger value="sources">Traffic Sources</TabsTrigger>
|
||||||
<TabsTrigger value="devices">Device Usage</TabsTrigger>
|
<TabsTrigger value="devices">Device Usage</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="pages" className="mt-4 space-y-2">
|
<TabsContent value="pages" className="mt-0 min-h-0 flex-1 overflow-y-auto dashboard-scroll max-h-[420px] xl:max-h-none">
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
columns={pagesColumns}
|
columns={pagesColumns}
|
||||||
data={data?.data?.pageData?.pageData || []}
|
data={data?.data?.pageData?.pageData || []}
|
||||||
getRowKey={(page, index) => `${page.path}-${index}`}
|
getRowKey={(page, index) => `${page.path}-${index}`}
|
||||||
maxHeight="xl"
|
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="sources" className="mt-4 space-y-2">
|
<TabsContent value="sources" className="mt-0 min-h-0 flex-1 overflow-y-auto dashboard-scroll max-h-[420px] xl:max-h-none">
|
||||||
<DashboardTable
|
<DashboardTable
|
||||||
columns={sourcesColumns}
|
columns={sourcesColumns}
|
||||||
data={data?.data?.sourceData || []}
|
data={data?.data?.sourceData || []}
|
||||||
getRowKey={(source, index) => `${source.source}-${index}`}
|
getRowKey={(source, index) => `${source.source}-${index}`}
|
||||||
maxHeight="xl"
|
|
||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="devices" className="mt-4 space-y-2">
|
{/* NOTE: TabsContent must not carry a bare display class (e.g. `flex`)
|
||||||
<div className={`h-60 ${CARD_STYLES.base} rounded-lg p-4`}>
|
— it would override Radix's [hidden] attribute and render the
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
inactive panel into the layout. Center via the inner wrapper. */}
|
||||||
<PieChart>
|
<TabsContent value="devices" className="mt-0 min-h-0 flex-1">
|
||||||
<Pie
|
{/* Studio donut: slice gaps + center total + labeled rows (no on-slice labels) */}
|
||||||
data={deviceData}
|
<div className="flex h-full min-h-[280px] w-full flex-col items-center justify-center gap-4 py-2 sm:flex-row sm:gap-10">
|
||||||
dataKey="pageViews"
|
<div className="relative h-48 w-48 shrink-0">
|
||||||
nameKey="device"
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
cx="50%"
|
<PieChart>
|
||||||
cy="50%"
|
<Pie
|
||||||
outerRadius={80}
|
data={deviceData}
|
||||||
labelLine={false}
|
dataKey="pageViews"
|
||||||
label={({ name, percent }) =>
|
nameKey="device"
|
||||||
`${name} ${(percent * 100).toFixed(1)}%`
|
cx="50%"
|
||||||
}
|
cy="50%"
|
||||||
>
|
innerRadius={58}
|
||||||
{deviceData.map((entry, index) => (
|
outerRadius={88}
|
||||||
<Cell
|
paddingAngle={2}
|
||||||
key={`cell-${index}`}
|
stroke="#ffffff"
|
||||||
fill={COLORS[entry.device.toLowerCase()]}
|
strokeWidth={2}
|
||||||
|
labelLine={false}
|
||||||
|
>
|
||||||
|
{deviceData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[entry.device.toLowerCase()]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
content={
|
||||||
|
<DashboardChartTooltip
|
||||||
|
labelFormatter={(_, payload) => payload?.[0]?.payload?.device || ""}
|
||||||
|
itemRenderer={deviceTooltipRenderer}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
|
||||||
|
<span className="text-[20px] font-bold tabular-nums text-[#2b2925]">
|
||||||
|
{totalViews >= 1000
|
||||||
|
? `${(totalViews / 1000).toFixed(1)}K`
|
||||||
|
: totalViews.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10.5px] font-semibold text-[#a09b92]">views</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full max-w-[280px] space-y-2.5">
|
||||||
|
{deviceData.map((entry) => {
|
||||||
|
const pct =
|
||||||
|
totalViews > 0
|
||||||
|
? ((entry.pageViews / totalViews) * 100).toFixed(1)
|
||||||
|
: "0.0";
|
||||||
|
return (
|
||||||
|
<div key={entry.device} className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ background: COLORS[entry.device.toLowerCase()] }}
|
||||||
/>
|
/>
|
||||||
))}
|
<span className="flex-1 text-xs font-medium text-[#2b2925]">
|
||||||
</Pie>
|
{entry.device}
|
||||||
<Tooltip
|
</span>
|
||||||
content={
|
<span className="text-xs tabular-nums text-[#7c7870]">
|
||||||
<DashboardChartTooltip
|
{entry.pageViews.toLocaleString()}
|
||||||
labelFormatter={(_, payload) => payload?.[0]?.payload?.device || ""}
|
</span>
|
||||||
itemRenderer={deviceTooltipRenderer}
|
<span className="w-12 text-right text-xs font-bold tabular-nums text-[#2b2925]">
|
||||||
/>
|
{pct}%
|
||||||
}
|
</span>
|
||||||
/>
|
</div>
|
||||||
</PieChart>
|
);
|
||||||
</ResponsiveContainer>
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,866 @@
|
|||||||
|
/**
|
||||||
|
* NightboardSmall — the /small kiosk dashboard, Nightboard style.
|
||||||
|
*
|
||||||
|
* Designed for the office panel: 10.1" 1920×1200 at 1× CSS scale, read from
|
||||||
|
* across the room. Hierarchy comes from scale and spacing, not cards — sizes
|
||||||
|
* below are tuned to that fixed viewport, so no transform-scale hacks.
|
||||||
|
* Data hooks mirror the retired Mini* components one-to-one; only the
|
||||||
|
* presentation changed (weather/calendar dropped, clock lives in the top line).
|
||||||
|
*/
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
AreaChart,
|
||||||
|
Area,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
|
import { apiClient } from "@/utils/apiClient";
|
||||||
|
import { apiFetch } from "@/utils/api";
|
||||||
|
import config from "@/config";
|
||||||
|
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases";
|
||||||
|
// @ts-expect-error - JSX module without type declarations
|
||||||
|
import { processBasicData } from "@/components/dashboard/RealtimeAnalytics";
|
||||||
|
// @ts-expect-error - JSX module without type declarations
|
||||||
|
import { EventDialog } from "@/components/dashboard/EventFeed.jsx";
|
||||||
|
import LockButton from "@/components/dashboard/LockButton";
|
||||||
|
|
||||||
|
// ── palette (Nightboard) ─────────────────────────────────────────────────────
|
||||||
|
const NB = {
|
||||||
|
bg: "#0e1420",
|
||||||
|
txt: "#e6ebf4",
|
||||||
|
mut: "#8292a8",
|
||||||
|
faint: "#46536b",
|
||||||
|
line: "#1d2738",
|
||||||
|
amberText: "#ffb454", // large type only
|
||||||
|
amber: "#c9822a", // chart marks (CVD-validated on this ground)
|
||||||
|
up: "#58b98a",
|
||||||
|
dn: "#d4707c",
|
||||||
|
card: "#131b2c",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// ── formatters ───────────────────────────────────────────────────────────────
|
||||||
|
const fmtMoney = (v: number | null | undefined) =>
|
||||||
|
v == null || isNaN(v)
|
||||||
|
? "—"
|
||||||
|
: new Intl.NumberFormat("en-US", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "USD",
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(v);
|
||||||
|
|
||||||
|
const fmtCompact = (v: number | null | undefined) => {
|
||||||
|
if (v == null || isNaN(v)) return "—";
|
||||||
|
if (Math.abs(v) >= 1_000_000) return `$${(v / 1_000_000).toFixed(2)}M`;
|
||||||
|
if (Math.abs(v) >= 1_000) return `$${(v / 1_000).toFixed(0)}K`;
|
||||||
|
return `$${Math.round(v)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtCount = (v: number | null | undefined) => {
|
||||||
|
if (v == null || isNaN(v)) return "—";
|
||||||
|
if (v >= 10_000) return `${(v / 1_000).toFixed(1)}K`;
|
||||||
|
return v.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const trendPct = (cur: number, prev: number) =>
|
||||||
|
prev > 0 ? ((cur - prev) / prev) * 100 : null;
|
||||||
|
|
||||||
|
// ── tiny presentational atoms ────────────────────────────────────────────────
|
||||||
|
const Lbl = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div
|
||||||
|
className="text-[17px] font-medium uppercase"
|
||||||
|
style={{ color: NB.mut, letterSpacing: "0.22em" }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const TrendArrow = ({ pct, invert = false }: { pct: number | null; invert?: boolean }) => {
|
||||||
|
if (pct == null || Math.abs(pct) < 0.1) return null;
|
||||||
|
const good = invert ? pct < 0 : pct > 0;
|
||||||
|
return (
|
||||||
|
<span style={{ color: good ? NB.up : NB.dn }}>
|
||||||
|
{pct > 0 ? "▲" : "▼"} {Math.abs(pct).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── metric ids for the event feed (same Klaviyo metrics as MiniEventFeed) ────
|
||||||
|
const METRIC_IDS = {
|
||||||
|
PLACED_ORDER: "Y8cqcF",
|
||||||
|
SHIPPED_ORDER: "VExpdL",
|
||||||
|
ACCOUNT_CREATED: "TeeypV",
|
||||||
|
CANCELED_ORDER: "YjVMNg",
|
||||||
|
NEW_BLOG_POST: "YcxeDr",
|
||||||
|
PAYMENT_REFUNDED: "R7XUYh",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const EVENT_META: Record<string, { label: string; dot: string }> = {
|
||||||
|
[METRIC_IDS.PLACED_ORDER]: { label: "Order", dot: NB.up },
|
||||||
|
[METRIC_IDS.SHIPPED_ORDER]: { label: "Shipped", dot: "#5580d0" },
|
||||||
|
[METRIC_IDS.ACCOUNT_CREATED]: { label: "Account", dot: "#8f7ad6" },
|
||||||
|
[METRIC_IDS.CANCELED_ORDER]: { label: "Canceled", dot: NB.dn },
|
||||||
|
[METRIC_IDS.PAYMENT_REFUNDED]: { label: "Refund", dot: "#e0a15a" },
|
||||||
|
[METRIC_IDS.NEW_BLOG_POST]: { label: "Blog", dot: NB.mut },
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtEventMoney = (amount: string | number | undefined) => {
|
||||||
|
const num = typeof amount === "string" ? parseFloat(amount) : amount;
|
||||||
|
if (num == null || isNaN(num)) return "";
|
||||||
|
return `${num < 0 ? "-" : ""}$${Math.abs(num).toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const shipMethod = (method: string | undefined) => {
|
||||||
|
if (!method) return "Digital";
|
||||||
|
if (method.includes("usps")) return "USPS";
|
||||||
|
if (method.includes("fedex")) return "FedEx";
|
||||||
|
if (method.includes("ups")) return "UPS";
|
||||||
|
return "Standard";
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EventProps {
|
||||||
|
ShippingName?: string;
|
||||||
|
OrderId?: string | number;
|
||||||
|
TotalAmount?: string | number;
|
||||||
|
IsOnHold?: boolean;
|
||||||
|
StillOwes?: boolean;
|
||||||
|
LocalPickup?: boolean;
|
||||||
|
HasPreorder?: boolean;
|
||||||
|
ShipMethod?: string;
|
||||||
|
ShippedBy?: string;
|
||||||
|
FirstName?: string;
|
||||||
|
LastName?: string;
|
||||||
|
EmailAddress?: string;
|
||||||
|
CancelReason?: string;
|
||||||
|
FromOrder?: string | number;
|
||||||
|
PaymentAmount?: string | number;
|
||||||
|
PaymentName?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeedEvent {
|
||||||
|
id: string;
|
||||||
|
metric_id: string;
|
||||||
|
datetime?: string;
|
||||||
|
attributes?: { datetime?: string; event_properties?: EventProps };
|
||||||
|
event_properties: EventProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PhaseSlice {
|
||||||
|
phase: string;
|
||||||
|
revenue: number;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── response shapes (acotService is untyped JS; these mirror what the API returns) ──
|
||||||
|
interface TodayStats {
|
||||||
|
revenue: number;
|
||||||
|
orderCount: number;
|
||||||
|
itemCount: number;
|
||||||
|
averageOrderValue: number;
|
||||||
|
averageItemsPerOrder: number;
|
||||||
|
periodProgress: number;
|
||||||
|
prevPeriodRevenue: number;
|
||||||
|
prevPeriodOrders: number;
|
||||||
|
prevPeriodAOV: number;
|
||||||
|
projectedRevenue?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Projection {
|
||||||
|
projectedRevenue?: number;
|
||||||
|
projectedOrders?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DailyRow {
|
||||||
|
revenue?: number;
|
||||||
|
orders?: number;
|
||||||
|
prevRevenue?: number;
|
||||||
|
prevOrders?: number;
|
||||||
|
periodProgress?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FinancialsResponse {
|
||||||
|
totals?: Record<string, number>;
|
||||||
|
previousTotals?: Record<string, number>;
|
||||||
|
comparison?: { margin?: { absolute?: number } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── clock ────────────────────────────────────────────────────────────────────
|
||||||
|
const Clock = () => {
|
||||||
|
const [now, setNow] = useState(() => new Date());
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => setNow(new Date()), 10_000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<span className="text-[19px]" style={{ color: NB.mut, letterSpacing: "0.06em" }}>
|
||||||
|
{now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
|
||||||
|
{" · "}
|
||||||
|
<span style={{ color: NB.txt, fontWeight: 500 }}>
|
||||||
|
{now.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" })}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── main component ───────────────────────────────────────────────────────────
|
||||||
|
const NightboardSmall = () => {
|
||||||
|
// Today stats (same source as MiniStatCards)
|
||||||
|
const { data: stats } = useQuery({
|
||||||
|
queryKey: ["nightboard-stats-today"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = (await acotService.getStats({ timeRange: "today" })) as { stats: TodayStats };
|
||||||
|
return res.stats;
|
||||||
|
},
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: projection } = useQuery({
|
||||||
|
queryKey: ["nightboard-projection-today"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await acotService.getProjection({ timeRange: "today" })) as Projection,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: realtime } = useQuery({
|
||||||
|
queryKey: ["nightboard-realtime-users"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiFetch("/api/dashboard-analytics/realtime/basic", {
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch realtime");
|
||||||
|
const result = await response.json();
|
||||||
|
return processBasicData(result.data) as { last30MinUsers: number; last5MinUsers: number };
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 30-day summary + avg/day (same source as MiniSalesChart / MiniBusinessMetrics)
|
||||||
|
const { data: sum30 } = useQuery({
|
||||||
|
queryKey: ["nightboard-stats-30d"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = (await acotService.getStatsDetails({
|
||||||
|
timeRange: "last30days",
|
||||||
|
metric: "revenue",
|
||||||
|
daily: true,
|
||||||
|
})) as { stats?: DailyRow[] };
|
||||||
|
const rows = Array.isArray(response.stats) ? response.stats : [];
|
||||||
|
const t = rows.reduce<Required<DailyRow>>(
|
||||||
|
(acc, day) => ({
|
||||||
|
revenue: acc.revenue + (Number(day.revenue) || 0),
|
||||||
|
orders: acc.orders + (Number(day.orders) || 0),
|
||||||
|
prevRevenue: acc.prevRevenue + (Number(day.prevRevenue) || 0),
|
||||||
|
prevOrders: acc.prevOrders + (Number(day.prevOrders) || 0),
|
||||||
|
periodProgress: day.periodProgress || 100,
|
||||||
|
}),
|
||||||
|
{ revenue: 0, orders: 0, prevRevenue: 0, prevOrders: 0, periodProgress: 100 }
|
||||||
|
);
|
||||||
|
const days = rows.length || 1;
|
||||||
|
return {
|
||||||
|
...t,
|
||||||
|
avgPerDay: t.revenue / days,
|
||||||
|
prevAvgPerDay: t.prevRevenue / days,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: projection30 } = useQuery({
|
||||||
|
queryKey: ["nightboard-projection-30d"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await acotService.getProjection({ timeRange: "last30days" })) as Projection,
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 30-day chart + phase mix (same endpoint as MiniSalesChart)
|
||||||
|
const { data: chartData } = useQuery({
|
||||||
|
queryKey: ["nightboard-sales-chart-30d"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const now = new Date();
|
||||||
|
const thirtyDaysAgo = new Date(now);
|
||||||
|
thirtyDaysAgo.setDate(now.getDate() - 30);
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
startDate: thirtyDaysAgo.toISOString(),
|
||||||
|
endDate: now.toISOString(),
|
||||||
|
});
|
||||||
|
const response = await apiFetch(`${config.apiUrl}/dashboard/sales/metrics?${params}`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch sales metrics");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Business band (same sources as MiniBusinessMetrics)
|
||||||
|
const { data: forecastData } = useQuery({
|
||||||
|
queryKey: ["nightboard-forecast-30d"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiFetch(`${config.apiUrl}/dashboard/forecast/metrics`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch forecast");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 600_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: yearData } = useQuery({
|
||||||
|
queryKey: ["nightboard-year-estimate"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiFetch(`${config.apiUrl}/dashboard/year-revenue-estimate`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch year estimate");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 600_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: financialData } = useQuery({
|
||||||
|
queryKey: ["nightboard-financials-30d"],
|
||||||
|
queryFn: async () =>
|
||||||
|
(await acotService.getFinancials({ timeRange: "last30days" })) as FinancialsResponse,
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: fteData } = useQuery({
|
||||||
|
queryKey: ["nightboard-fte-30d"],
|
||||||
|
queryFn: async () =>
|
||||||
|
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
|
||||||
|
(await acotService.getEmployeeMetrics({ timeRange: "last30days" })) as {
|
||||||
|
totals?: { fte?: number };
|
||||||
|
previousTotals?: { fte?: number };
|
||||||
|
},
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ops / inventory band (same sources as MiniInventorySnapshot)
|
||||||
|
const { data: opsData } = useQuery({
|
||||||
|
queryKey: ["nightboard-ops-today"],
|
||||||
|
queryFn: async () =>
|
||||||
|
// @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type
|
||||||
|
(await acotService.getOperationsMetrics({ timeRange: "today" })) as {
|
||||||
|
totals?: { ordersShipped?: number; piecesPicked?: number };
|
||||||
|
},
|
||||||
|
refetchInterval: 120_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: stockData } = useQuery({
|
||||||
|
queryKey: ["nightboard-stock-metrics"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiFetch(`${config.apiUrl}/dashboard/stock/metrics`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch stock metrics");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: replenishData } = useQuery({
|
||||||
|
queryKey: ["nightboard-replenish-metrics"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiFetch(`${config.apiUrl}/dashboard/replenishment/metrics`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch replenishment");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: overstockData } = useQuery({
|
||||||
|
queryKey: ["nightboard-overstock-metrics"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiFetch(`${config.apiUrl}/dashboard/overstock/metrics`);
|
||||||
|
if (!response.ok) throw new Error("Failed to fetch overstock");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
refetchInterval: 300_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event feed (same source as MiniEventFeed)
|
||||||
|
const { data: events = [] } = useQuery<FeedEvent[]>({
|
||||||
|
queryKey: ["nightboard-event-feed"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await apiClient.get("/api/klaviyo/events/feed", {
|
||||||
|
params: {
|
||||||
|
timeRange: "today",
|
||||||
|
metricIds: JSON.stringify(Object.values(METRIC_IDS)),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (response.data.data || []).map((event: FeedEvent) => ({
|
||||||
|
...event,
|
||||||
|
datetime: event.attributes?.datetime || event.datetime,
|
||||||
|
event_properties: event.attributes?.event_properties || {},
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
refetchInterval: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const feedRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (feedRef.current && events.length > 0) {
|
||||||
|
const el = feedRef.current;
|
||||||
|
const t = setTimeout(
|
||||||
|
() => el.scrollTo({ left: el.scrollWidth, behavior: "instant" as ScrollBehavior }),
|
||||||
|
150
|
||||||
|
);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}
|
||||||
|
}, [events]);
|
||||||
|
|
||||||
|
// ── derived values ─────────────────────────────────────────────────────────
|
||||||
|
const inProgress = stats != null && stats.periodProgress < 100;
|
||||||
|
const projRevenue = projection?.projectedRevenue ?? stats?.projectedRevenue ?? null;
|
||||||
|
const revTrend =
|
||||||
|
stats && stats.prevPeriodRevenue > 0
|
||||||
|
? trendPct(
|
||||||
|
inProgress ? projRevenue ?? stats.revenue : stats.revenue,
|
||||||
|
stats.prevPeriodRevenue
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
const projOrders =
|
||||||
|
projection?.projectedOrders ??
|
||||||
|
(stats && stats.periodProgress > 0
|
||||||
|
? Math.round(stats.orderCount / (stats.periodProgress / 100))
|
||||||
|
: null);
|
||||||
|
const ordersTrend =
|
||||||
|
stats && stats.prevPeriodOrders > 0
|
||||||
|
? trendPct(
|
||||||
|
inProgress ? projOrders ?? stats.orderCount : stats.orderCount,
|
||||||
|
stats.prevPeriodOrders
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const rev30Current =
|
||||||
|
sum30 && sum30.periodProgress < 100
|
||||||
|
? projection30?.projectedRevenue || sum30.revenue
|
||||||
|
: sum30?.revenue;
|
||||||
|
const rev30Trend = sum30 ? trendPct(rev30Current ?? 0, sum30.prevRevenue) : null;
|
||||||
|
const orders30Current =
|
||||||
|
sum30 && sum30.periodProgress < 100 && sum30.periodProgress > 0
|
||||||
|
? Math.round(sum30.orders * (100 / sum30.periodProgress))
|
||||||
|
: sum30?.orders;
|
||||||
|
const orders30Trend = sum30 ? trendPct(orders30Current ?? 0, sum30.prevOrders) : null;
|
||||||
|
|
||||||
|
const dailyTotals: { date: string; total: number }[] = (chartData?.dailySalesByPhase || []).map(
|
||||||
|
(day: { date: string } & Record<string, unknown>) => ({
|
||||||
|
date: day.date,
|
||||||
|
total: PHASE_KEYS.reduce((sum, key) => sum + (Number(day[key]) || 0), 0),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const activePhases: PhaseSlice[] = (chartData?.phaseBreakdown || [])
|
||||||
|
.filter((p: PhaseSlice) => p.revenue > 0)
|
||||||
|
.sort((a: PhaseSlice, b: PhaseSlice) => b.revenue - a.revenue);
|
||||||
|
|
||||||
|
// margin (same fallback math as MiniBusinessMetrics)
|
||||||
|
let margin: number | null = null;
|
||||||
|
let prevMargin: number | null = null;
|
||||||
|
if (financialData?.totals) {
|
||||||
|
const t = financialData.totals;
|
||||||
|
if (Number.isFinite(t.margin)) margin = t.margin;
|
||||||
|
else {
|
||||||
|
const income = (t.grossSales || 0) - (t.refunds || 0) - (t.discounts || 0) + (t.shippingFees || 0);
|
||||||
|
margin = income > 0 ? ((income - (t.cogs || 0)) / income) * 100 : 0;
|
||||||
|
}
|
||||||
|
const prev = financialData.previousTotals;
|
||||||
|
if (prev) {
|
||||||
|
if (Number.isFinite(prev.margin)) prevMargin = prev.margin;
|
||||||
|
else {
|
||||||
|
const pIncome =
|
||||||
|
(prev.grossSales || 0) - (prev.refunds || 0) - (prev.discounts || 0) + (prev.shippingFees || 0);
|
||||||
|
prevMargin = pIncome > 0 ? ((pIncome - (prev.cogs || 0)) / pIncome) * 100 : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fte = fteData?.totals?.fte ?? null;
|
||||||
|
const prevFte = fteData?.previousTotals?.fte ?? null;
|
||||||
|
const ops = opsData?.totals;
|
||||||
|
|
||||||
|
const band: { label: string; value: string; sub: React.ReactNode; group?: boolean }[] = [
|
||||||
|
{
|
||||||
|
label: "Forecast 30d",
|
||||||
|
value: fmtCompact(forecastData?.forecastRevenue),
|
||||||
|
sub: yearData?.yearTotal != null ? `${fmtCompact(yearData.yearTotal)} for year` : "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Avg rev/day",
|
||||||
|
value: sum30 ? fmtMoney(sum30.avgPerDay) : "—",
|
||||||
|
sub: sum30 ? (
|
||||||
|
<>
|
||||||
|
prev {fmtMoney(sum30.prevAvgPerDay)}{" "}
|
||||||
|
<TrendArrow pct={trendPct(sum30.avgPerDay, sum30.prevAvgPerDay)} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Margin",
|
||||||
|
value: margin != null ? `${margin.toFixed(1)}%` : "—",
|
||||||
|
sub:
|
||||||
|
prevMargin != null && margin != null ? (
|
||||||
|
<>
|
||||||
|
prev {prevMargin.toFixed(1)}%{" "}
|
||||||
|
<span style={{ color: margin >= prevMargin ? NB.up : NB.dn }}>
|
||||||
|
{margin >= prevMargin ? "▲" : "▼"} {Math.abs(margin - prevMargin).toFixed(1)}pp
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Payroll FTE",
|
||||||
|
value: fte != null ? fte.toFixed(1) : "—",
|
||||||
|
sub:
|
||||||
|
prevFte != null && fte != null ? (
|
||||||
|
<>
|
||||||
|
prev {prevFte.toFixed(1)} <TrendArrow pct={trendPct(fte, prevFte)} invert />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Shipped today",
|
||||||
|
value: ops ? fmtCount(ops.ordersShipped) : "—",
|
||||||
|
sub: ops ? `${fmtCount(ops.piecesPicked)} pcs picked` : "—",
|
||||||
|
group: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Stock value",
|
||||||
|
value: fmtCompact(stockData?.totalStockCost),
|
||||||
|
sub: stockData ? `${fmtCount(stockData.productsInStock)} products` : "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Replenish",
|
||||||
|
value: replenishData ? `${fmtCount(replenishData.unitsToReplenish)} u` : "—",
|
||||||
|
sub: replenishData ? `${fmtCompact(replenishData.replenishmentCost)} cost` : "—",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Overstocked",
|
||||||
|
value: overstockData ? fmtCount(overstockData.overstockedProducts) : "—",
|
||||||
|
sub: overstockData ? `${fmtCompact(overstockData.totalExcessCost)} excess` : "—",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="h-screen w-screen overflow-hidden grid grid-rows-[auto_auto_minmax(0,1fr)_auto_auto] grid-cols-[minmax(0,1fr)] px-12 pt-8"
|
||||||
|
style={{
|
||||||
|
background: `radial-gradient(1200px 500px at 25% -10%, rgba(22,32,50,0.45) 0%, transparent 60%), ${NB.bg}`,
|
||||||
|
color: NB.txt,
|
||||||
|
fontVariantNumeric: "tabular-nums",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* top line */}
|
||||||
|
<div className="flex items-baseline justify-between pb-6">
|
||||||
|
<span
|
||||||
|
className="text-[18px] font-medium uppercase"
|
||||||
|
style={{ color: NB.mut, letterSpacing: "0.3em" }}
|
||||||
|
>
|
||||||
|
A Cherry On Top
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Clock />
|
||||||
|
<span className="opacity-30 hover:opacity-100 transition-opacity">
|
||||||
|
<LockButton />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* hero metrics */}
|
||||||
|
<div className="grid grid-cols-[1.5fr_1fr_1fr_1fr] gap-10 items-end pb-8">
|
||||||
|
<div>
|
||||||
|
<Lbl>Revenue today</Lbl>
|
||||||
|
<div
|
||||||
|
className="text-[112px] font-extralight leading-[0.95] mt-2"
|
||||||
|
style={{ color: NB.amberText, letterSpacing: "-0.03em" }}
|
||||||
|
>
|
||||||
|
{fmtMoney(stats?.revenue)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[20px] mt-3" style={{ color: NB.mut }}>
|
||||||
|
{inProgress && projRevenue != null && (
|
||||||
|
<>
|
||||||
|
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtMoney(projRevenue)}</span>
|
||||||
|
{" · "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TrendArrow pct={revTrend} /> {revTrend != null && "vs yesterday"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Lbl>Orders</Lbl>
|
||||||
|
<div className="text-[58px] font-light leading-none mt-2">
|
||||||
|
{stats?.orderCount?.toLocaleString() ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||||
|
{projOrders != null && inProgress && (
|
||||||
|
<>
|
||||||
|
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{projOrders}</span>
|
||||||
|
{" · "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<TrendArrow pct={ordersTrend} /> {ordersTrend != null && "vs yesterday"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Lbl>Avg order</Lbl>
|
||||||
|
<div className="text-[58px] font-light leading-none mt-2">
|
||||||
|
{stats?.averageOrderValue != null ? `$${stats.averageOrderValue.toFixed(2)}` : "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||||
|
{stats?.averageItemsPerOrder != null
|
||||||
|
? `${stats.averageItemsPerOrder.toFixed(1)} items / order`
|
||||||
|
: ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Lbl>On site now</Lbl>
|
||||||
|
<div className="text-[58px] font-light leading-none mt-2 flex items-center gap-4">
|
||||||
|
<span className="relative inline-flex h-3 w-3">
|
||||||
|
<span
|
||||||
|
className="absolute inline-flex h-full w-full rounded-full opacity-75 motion-safe:animate-ping"
|
||||||
|
style={{ background: NB.up }}
|
||||||
|
/>
|
||||||
|
<span className="relative inline-flex h-3 w-3 rounded-full" style={{ background: NB.up }} />
|
||||||
|
</span>
|
||||||
|
{realtime?.last5MinUsers ?? "—"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||||
|
{realtime != null ? `${realtime.last30MinUsers} last 30 min` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 30-day chart */}
|
||||||
|
<div className="flex flex-col min-h-0 pt-4" style={{ borderTop: `1px solid ${NB.line}` }}>
|
||||||
|
<div className="flex items-baseline justify-between pb-1">
|
||||||
|
<Lbl>Last 30 days</Lbl>
|
||||||
|
<span className="text-[19px]" style={{ color: NB.mut }}>
|
||||||
|
Revenue <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtCompact(sum30?.revenue)}</span>{" "}
|
||||||
|
<TrendArrow pct={rev30Trend} />
|
||||||
|
{" · "}
|
||||||
|
Orders <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtCount(sum30?.orders)}</span>{" "}
|
||||||
|
<TrendArrow pct={orders30Trend} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<AreaChart data={dailyTotals} margin={{ top: 10, right: 4, left: 4, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nbRevFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={NB.amber} stopOpacity={0.28} />
|
||||||
|
<stop offset="100%" stopColor={NB.amber} stopOpacity={0} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tickFormatter={(v: string) =>
|
||||||
|
new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" })
|
||||||
|
}
|
||||||
|
tick={{ fill: NB.mut, fontSize: 16 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
interval={5}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(v: number) => fmtCompact(v)}
|
||||||
|
tick={{ fill: NB.mut, fontSize: 16 }}
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
width={58}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
cursor={{ stroke: NB.faint, strokeDasharray: "3 4" }}
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload?.length) return null;
|
||||||
|
const row = payload[0].payload as { date: string; total: number };
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg px-4 py-2.5 text-[17px]"
|
||||||
|
style={{ background: NB.card, border: `1px solid ${NB.line}`, color: NB.txt }}
|
||||||
|
>
|
||||||
|
<div style={{ color: NB.mut }}>
|
||||||
|
{new Date(row.date).toLocaleDateString([], {
|
||||||
|
weekday: "short",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">{fmtMoney(row.total)}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="total"
|
||||||
|
stroke={NB.amber}
|
||||||
|
strokeWidth={2.5}
|
||||||
|
fill="url(#nbRevFill)"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 5, fill: NB.amberText, stroke: "none" }}
|
||||||
|
/>
|
||||||
|
</AreaChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{/* lifecycle-phase mix */}
|
||||||
|
{activePhases.length > 0 && (
|
||||||
|
<div className="flex items-center gap-5 pt-2 pb-3">
|
||||||
|
<div className="flex flex-1 gap-[3px]" style={{ height: 6 }}>
|
||||||
|
{activePhases.map((p) => (
|
||||||
|
<div
|
||||||
|
key={p.phase}
|
||||||
|
className="rounded-[3px]"
|
||||||
|
style={{
|
||||||
|
width: `${p.percentage}%`,
|
||||||
|
background: PHASE_CONFIG[p.phase]?.color || "#94A3B8",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-5 text-[15px]" style={{ color: NB.mut }}>
|
||||||
|
{activePhases.slice(0, 5).map((p) => (
|
||||||
|
<span key={p.phase} className="flex items-center gap-2 whitespace-nowrap">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2.5 w-2.5 rounded-[3px]"
|
||||||
|
style={{ background: PHASE_CONFIG[p.phase]?.color || "#94A3B8" }}
|
||||||
|
/>
|
||||||
|
{PHASE_CONFIG[p.phase]?.label || p.phase}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* business + ops band */}
|
||||||
|
<div className="grid grid-cols-8 py-5" style={{ borderTop: `1px solid ${NB.line}` }}>
|
||||||
|
{band.map((cell, i) => (
|
||||||
|
<div
|
||||||
|
key={cell.label}
|
||||||
|
className="px-5 first:pl-0"
|
||||||
|
style={{
|
||||||
|
borderLeft: i === 0 ? "none" : `1px solid ${cell.group ? "#2a3852" : "#1a2334"}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="text-[15px] font-medium uppercase"
|
||||||
|
style={{ color: NB.mut, letterSpacing: "0.18em" }}
|
||||||
|
>
|
||||||
|
{cell.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-[38px] font-light mt-1.5" style={{ letterSpacing: "-0.01em" }}>
|
||||||
|
{cell.value}
|
||||||
|
</div>
|
||||||
|
<div className="text-[17px] mt-1" style={{ color: NB.mut }}>
|
||||||
|
{cell.sub}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* live event feed */}
|
||||||
|
<div className="relative min-w-0" style={{ borderTop: `1px solid ${NB.line}` }}>
|
||||||
|
<div
|
||||||
|
ref={feedRef}
|
||||||
|
className="overflow-x-auto overflow-y-hidden py-4 [&::-webkit-scrollbar]:hidden"
|
||||||
|
style={{ scrollbarWidth: "none" }}
|
||||||
|
>
|
||||||
|
<div className="flex gap-3 px-1" style={{ width: "max-content" }}>
|
||||||
|
{[...events].reverse().map((event) => {
|
||||||
|
const meta = EVENT_META[event.metric_id];
|
||||||
|
if (!meta) return null;
|
||||||
|
const d = event.event_properties;
|
||||||
|
let name = "";
|
||||||
|
let detail: React.ReactNode = "";
|
||||||
|
if (event.metric_id === METRIC_IDS.PLACED_ORDER) {
|
||||||
|
name = d.ShippingName ?? "";
|
||||||
|
const flags = [
|
||||||
|
d.IsOnHold && "On Hold",
|
||||||
|
d.StillOwes && "Owes",
|
||||||
|
d.LocalPickup && "Local",
|
||||||
|
d.HasPreorder && "Pre-order",
|
||||||
|
].filter(Boolean);
|
||||||
|
detail = (
|
||||||
|
<>
|
||||||
|
#{d.OrderId} · <span style={{ color: NB.amberText }}>{fmtEventMoney(d.TotalAmount)}</span>
|
||||||
|
{flags.length > 0 && ` · ${flags.join(" · ")}`}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if (event.metric_id === METRIC_IDS.SHIPPED_ORDER) {
|
||||||
|
name = d.ShippingName ?? "";
|
||||||
|
detail = `#${d.OrderId} · ${shipMethod(d.ShipMethod)}${d.ShippedBy ? ` · by ${d.ShippedBy}` : ""}`;
|
||||||
|
} else if (event.metric_id === METRIC_IDS.ACCOUNT_CREATED) {
|
||||||
|
name = `${d.FirstName ?? ""} ${d.LastName ?? ""}`.trim();
|
||||||
|
detail = d.EmailAddress;
|
||||||
|
} else if (event.metric_id === METRIC_IDS.CANCELED_ORDER) {
|
||||||
|
name = d.ShippingName ?? "";
|
||||||
|
detail = (
|
||||||
|
<>
|
||||||
|
#{d.OrderId} · {fmtEventMoney(d.TotalAmount)}
|
||||||
|
{d.CancelReason ? ` · ${d.CancelReason}` : ""}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if (event.metric_id === METRIC_IDS.PAYMENT_REFUNDED) {
|
||||||
|
name = d.ShippingName ?? "";
|
||||||
|
detail = (
|
||||||
|
<>
|
||||||
|
#{d.FromOrder} · {fmtEventMoney(d.PaymentAmount)}
|
||||||
|
{d.PaymentName ? ` · ${d.PaymentName}` : ""}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else if (event.metric_id === METRIC_IDS.NEW_BLOG_POST) {
|
||||||
|
name = d.title ?? "";
|
||||||
|
detail = d.description;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<EventDialog key={event.id} event={event} scale={1.75}>
|
||||||
|
<div
|
||||||
|
className="w-[315px] shrink-0 rounded-2xl px-5 py-4 cursor-pointer transition-colors hover:brightness-125"
|
||||||
|
style={{ background: NB.card }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between text-[13px] font-medium uppercase"
|
||||||
|
style={{ color: NB.mut, letterSpacing: "0.16em" }}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2.5">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={{ background: meta.dot }}
|
||||||
|
/>
|
||||||
|
{meta.label}
|
||||||
|
</span>
|
||||||
|
{event.datetime && (
|
||||||
|
<span style={{ letterSpacing: "0.04em" }}>
|
||||||
|
{new Date(event.datetime).toLocaleTimeString("en-US", {
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-[23px] font-medium mt-2 truncate">{name || "—"}</div>
|
||||||
|
<div className="text-[18px] mt-1 truncate" style={{ color: NB.mut }}>
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EventDialog>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* edge fades */}
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-y-0 left-0 w-14"
|
||||||
|
style={{ background: `linear-gradient(to right, ${NB.bg}, transparent)` }}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute inset-y-0 right-0 w-14"
|
||||||
|
style={{ background: `linear-gradient(to left, ${NB.bg}, transparent)` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NightboardSmall;
|
||||||
@@ -31,7 +31,7 @@ export interface BusinessRangeSelectProps {
|
|||||||
onValueChange: (value: string) => void;
|
onValueChange: (value: string) => void;
|
||||||
/** Preset options. Defaults to the canonical TIME_RANGES list. */
|
/** Preset options. Defaults to the canonical TIME_RANGES list. */
|
||||||
options?: RangeOption[];
|
options?: RangeOption[];
|
||||||
/** Merged onto the SelectTrigger (default sizing is w-[130px] h-9). */
|
/** Merged onto the SelectTrigger (default sizing is w-[130px] h-8). */
|
||||||
className?: string;
|
className?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -47,10 +47,16 @@ export function BusinessRangeSelect({
|
|||||||
}: BusinessRangeSelectProps) {
|
}: BusinessRangeSelectProps) {
|
||||||
return (
|
return (
|
||||||
<Select value={value} onValueChange={onValueChange}>
|
<Select value={value} onValueChange={onValueChange}>
|
||||||
<SelectTrigger id={id} className={cn("w-[130px] h-9", className)}>
|
<SelectTrigger
|
||||||
|
id={id}
|
||||||
|
className={cn(
|
||||||
|
"h-8 w-auto gap-1.5 rounded-full border-border bg-card px-3.5 text-xs font-semibold text-foreground shadow-none hover:bg-surface-subtle focus:ring-1 focus:ring-coral/30 focus:ring-offset-0",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
<SelectValue placeholder={placeholder} />
|
<SelectValue placeholder={placeholder} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent className="rounded-xl">
|
||||||
{options.map((range) => (
|
{options.map((range) => (
|
||||||
<SelectItem key={range.value} value={range.value}>
|
<SelectItem key={range.value} value={range.value}>
|
||||||
{range.label}
|
{range.label}
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export interface DashboardSectionHeaderProps {
|
|||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
/** Size variant for title */
|
/** Size variant for title */
|
||||||
size?: "default" | "large";
|
size?: "default" | "large";
|
||||||
|
/** Show the accent bar next to the title (turn off for nested cards) */
|
||||||
|
accent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -83,11 +85,12 @@ export interface DashboardSectionHeaderProps {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const defaultLastUpdatedFormat = (date: Date): string => {
|
const defaultLastUpdatedFormat = (date: Date): string => {
|
||||||
return date.toLocaleTimeString("en-US", {
|
const time = date.toLocaleTimeString("en-US", {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
hour12: true,
|
hour12: true,
|
||||||
});
|
});
|
||||||
|
return `Updated ${time}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -105,55 +108,60 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
|
|||||||
className,
|
className,
|
||||||
compact = false,
|
compact = false,
|
||||||
size = "default",
|
size = "default",
|
||||||
|
accent = true,
|
||||||
}) => {
|
}) => {
|
||||||
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-2";
|
const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3";
|
||||||
const titleClass = size === "large"
|
const titleClass = size === "large"
|
||||||
? "text-xl font-semibold text-foreground"
|
? "text-[14px] font-semibold tracking-tight text-foreground"
|
||||||
: "text-lg font-semibold text-foreground";
|
: "text-[12.5px] font-semibold tracking-tight text-foreground";
|
||||||
|
void accent; // Studio design has no accent bar; prop kept for call-site compat
|
||||||
|
|
||||||
// Loading skeleton
|
// Loading skeleton
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<CardHeader className={cn(paddingClass, className)}>
|
<CardHeader className={cn("border-b border-muted", paddingClass, className)}>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Skeleton className="h-6 w-40 bg-muted" />
|
<Skeleton className="h-5 w-40 bg-muted" />
|
||||||
{description && <Skeleton className="h-4 w-56 bg-muted" />}
|
{description && <Skeleton className="h-4 w-56 bg-muted/60" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{timeSelector && <Skeleton className="h-9 w-[130px] bg-muted rounded-md" />}
|
{timeSelector && <Skeleton className="h-8 w-[130px] bg-muted rounded-full" />}
|
||||||
{actions && <Skeleton className="h-9 w-20 bg-muted rounded-md" />}
|
{actions && <Skeleton className="h-8 w-20 bg-muted rounded-full" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasRightContent = timeSelector || actions || (lastUpdated && !loading);
|
const hasRightContent = timeSelector || actions;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardHeader className={cn(paddingClass, className)}>
|
<CardHeader className={cn("border-b border-muted", paddingClass, className)}>
|
||||||
<div className="flex justify-between items-start gap-4">
|
<div className="flex flex-row items-center justify-between gap-3">
|
||||||
{/* Left side: Title and description */}
|
{/* Left side: title (+ optional description / updated time inline) */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className={titleClass}>{title}</CardTitle>
|
<div className="flex flex-wrap items-baseline gap-x-2.5">
|
||||||
|
<CardTitle className={titleClass}>{title}</CardTitle>
|
||||||
|
{lastUpdated && !loading && (
|
||||||
|
<span className="text-[10.5px] text-muted-foreground/75">
|
||||||
|
{lastUpdatedFormat(lastUpdated)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
{description && (
|
{description && (
|
||||||
<CardDescription className={cn(TYPOGRAPHY.cardDescription, "mt-1")}>
|
<CardDescription className={cn(TYPOGRAPHY.cardDescription, "mt-0.5 hidden sm:block")}>
|
||||||
{description}
|
{description}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
)}
|
)}
|
||||||
{lastUpdated && !loading && (
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Last updated: {lastUpdatedFormat(lastUpdated)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side: Time selector and actions */}
|
{/* Right side: actions (filters / Details) sit left of the date range
|
||||||
|
selector for a consistent order across sections */}
|
||||||
{hasRightContent && (
|
{hasRightContent && (
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex flex-wrap items-center justify-end gap-1.5 flex-shrink-0">
|
||||||
{timeSelector}
|
|
||||||
{actions}
|
{actions}
|
||||||
|
{timeSelector}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,18 +188,18 @@ export const DashboardSectionHeaderSkeleton: React.FC<DashboardSectionHeaderSkel
|
|||||||
compact = false,
|
compact = false,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const paddingClass = compact ? "p-4 pb-2" : "p-6 pb-4";
|
const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardHeader className={cn(paddingClass, className)}>
|
<CardHeader className={cn("border-b border-muted", paddingClass, className)}>
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-start">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-6 w-40 bg-muted" />
|
<Skeleton className="h-5 w-40 bg-muted" />
|
||||||
{hasDescription && <Skeleton className="h-4 w-56 bg-muted" />}
|
{hasDescription && <Skeleton className="h-4 w-56 bg-muted/60" />}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{hasTimeSelector && <Skeleton className="h-9 w-[130px] bg-muted rounded-md" />}
|
{hasTimeSelector && <Skeleton className="h-8 w-[130px] bg-muted rounded-full" />}
|
||||||
{hasActions && <Skeleton className="h-9 w-20 bg-muted rounded-md" />}
|
{hasActions && <Skeleton className="h-8 w-20 bg-muted rounded-full" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,53 +1,27 @@
|
|||||||
/**
|
/**
|
||||||
* DashboardStatCard
|
* DashboardStatCard (Sorbet Studio)
|
||||||
*
|
*
|
||||||
* A reusable stat/metric card component for the dashboard.
|
* The shared stat/metric card. Clean white panel, warm border, no icons —
|
||||||
* Supports icons, trend indicators, tooltips, and multiple size variants.
|
* the number is the point. Trend renders as a tinted pill; up/down semantics
|
||||||
|
* live in the pill text color only.
|
||||||
|
*
|
||||||
|
* `icon`, `iconColor`, and `tooltip` are still accepted so existing call sites
|
||||||
|
* compile, but they intentionally render nothing: decorative per-metric icons
|
||||||
|
* and info-circles explaining obvious metrics were removed in the redesign.
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* // Basic usage
|
|
||||||
* <DashboardStatCard
|
|
||||||
* title="Total Revenue"
|
|
||||||
* value="$12,345"
|
|
||||||
* subtitle="Last 30 days"
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // With icon and trend
|
|
||||||
* <DashboardStatCard
|
* <DashboardStatCard
|
||||||
* title="Orders"
|
* title="Orders"
|
||||||
* value={1234}
|
* value={1234}
|
||||||
* trend={{ value: 12.5, label: "vs last month" }}
|
* subtitle="772 total items"
|
||||||
* icon={ShoppingCart}
|
* trend={{ value: 12.5 }}
|
||||||
* iconColor="blue"
|
|
||||||
* />
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* // With prefix/suffix and tooltip
|
|
||||||
* <DashboardStatCard
|
|
||||||
* title="Average Order Value"
|
|
||||||
* value={85.50}
|
|
||||||
* valuePrefix="$"
|
|
||||||
* valueSuffix="/order"
|
|
||||||
* tooltip="Calculated as total revenue divided by number of orders"
|
|
||||||
* />
|
* />
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { type LucideIcon } from "lucide-react";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { ArrowUp, ArrowDown, Minus, Info, type LucideIcon } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import { STUDIO_COLORS } from "@/lib/dashboard/designTokens";
|
||||||
CARD_STYLES,
|
|
||||||
TYPOGRAPHY,
|
|
||||||
STAT_ICON_STYLES,
|
|
||||||
getTrendColor,
|
|
||||||
} from "@/lib/dashboard/designTokens";
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -55,7 +29,6 @@ import {
|
|||||||
|
|
||||||
export type TrendDirection = "up" | "down" | "neutral";
|
export type TrendDirection = "up" | "down" | "neutral";
|
||||||
export type CardSize = "default" | "compact" | "large";
|
export type CardSize = "default" | "compact" | "large";
|
||||||
export type IconColor = keyof typeof STAT_ICON_STYLES.colors;
|
|
||||||
|
|
||||||
export interface TrendProps {
|
export interface TrendProps {
|
||||||
/** The percentage or absolute change value */
|
/** The percentage or absolute change value */
|
||||||
@@ -81,10 +54,10 @@ export interface DashboardStatCardProps {
|
|||||||
subtitle?: React.ReactNode;
|
subtitle?: React.ReactNode;
|
||||||
/** Optional trend indicator */
|
/** Optional trend indicator */
|
||||||
trend?: TrendProps;
|
trend?: TrendProps;
|
||||||
/** Optional icon component */
|
/** Accepted for compatibility; not rendered in the Studio design */
|
||||||
icon?: LucideIcon;
|
icon?: LucideIcon;
|
||||||
/** Icon color variant */
|
/** Accepted for compatibility; not rendered in the Studio design */
|
||||||
iconColor?: IconColor;
|
iconColor?: string;
|
||||||
/** Card size variant */
|
/** Card size variant */
|
||||||
size?: CardSize;
|
size?: CardSize;
|
||||||
/** Additional className for the card */
|
/** Additional className for the card */
|
||||||
@@ -93,89 +66,48 @@ export interface DashboardStatCardProps {
|
|||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
/** Loading state */
|
/** Loading state */
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
/** Tooltip text shown via info icon next to title */
|
/** Accepted for compatibility; not rendered in the Studio design */
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
/** Additional content to render below the main value */
|
/** Additional content to render below the main value */
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// HELPER COMPONENTS
|
// TREND PILL
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
interface TrendIndicatorProps {
|
interface TrendPillProps extends TrendProps {
|
||||||
value: number;
|
|
||||||
label?: string;
|
|
||||||
moreIsBetter?: boolean;
|
|
||||||
suffix?: string;
|
|
||||||
size?: CardSize;
|
size?: CardSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TrendIndicator: React.FC<TrendIndicatorProps> = ({
|
export const TrendPill: React.FC<TrendPillProps> = ({
|
||||||
value,
|
value,
|
||||||
label,
|
label,
|
||||||
moreIsBetter = true,
|
moreIsBetter = true,
|
||||||
suffix = "%",
|
suffix = "%",
|
||||||
size = "default",
|
|
||||||
}) => {
|
}) => {
|
||||||
const colors = getTrendColor(value, moreIsBetter);
|
if (value === 0) return null;
|
||||||
const direction: TrendDirection =
|
const isGood = (value > 0) === moreIsBetter;
|
||||||
value > 0 ? "up" : value < 0 ? "down" : "neutral";
|
const formattedValue = suffix === "%" ? Math.abs(value).toFixed(1) : Math.abs(value).toString();
|
||||||
|
|
||||||
const IconComponent =
|
|
||||||
direction === "up"
|
|
||||||
? ArrowUp
|
|
||||||
: direction === "down"
|
|
||||||
? ArrowDown
|
|
||||||
: Minus;
|
|
||||||
|
|
||||||
const iconSize = size === "compact" ? "h-3 w-3" : "h-3 w-3";
|
|
||||||
const textSize = size === "compact" ? "text-xs" : "text-xs";
|
|
||||||
|
|
||||||
// Format the value - use fixed decimal for percentages, integer for absolute values
|
|
||||||
const formattedValue = suffix === "%"
|
|
||||||
? value.toFixed(1)
|
|
||||||
: Math.abs(value).toString();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex items-center gap-1", colors.text)}>
|
<span className="inline-flex items-center gap-1">
|
||||||
<IconComponent className={iconSize} />
|
<span
|
||||||
<span className={cn("font-medium", textSize)}>
|
className="rounded-full px-1.5 py-px text-[10.5px] font-bold whitespace-nowrap"
|
||||||
{value > 0 && suffix === "%" ? "+" : ""}
|
style={{
|
||||||
{formattedValue}{suffix}
|
background: isGood ? "rgba(35,122,77,.09)" : "rgba(179,80,63,.09)",
|
||||||
|
color: isGood ? STUDIO_COLORS.up : STUDIO_COLORS.down,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value > 0 ? "↑" : "↓"} {formattedValue}
|
||||||
|
{suffix}
|
||||||
</span>
|
</span>
|
||||||
{label && (
|
{label && (
|
||||||
<span className={cn("text-muted-foreground", textSize)}>{label}</span>
|
<span className="text-[11px]" style={{ color: STUDIO_COLORS.muted }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</span>
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface IconContainerProps {
|
|
||||||
icon: LucideIcon;
|
|
||||||
color?: IconColor;
|
|
||||||
size?: CardSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
const IconContainer: React.FC<IconContainerProps> = ({
|
|
||||||
icon: Icon,
|
|
||||||
color = "blue",
|
|
||||||
size = "default",
|
|
||||||
}) => {
|
|
||||||
const colorStyles = STAT_ICON_STYLES.colors[color] || STAT_ICON_STYLES.colors.blue;
|
|
||||||
const containerSize = size === "compact" ? "p-1.5" : "p-2";
|
|
||||||
const iconSize = size === "compact" ? "h-3.5 w-3.5" : size === "large" ? "h-5 w-5" : "h-4 w-4";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
STAT_ICON_STYLES.container,
|
|
||||||
containerSize,
|
|
||||||
colorStyles.container
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className={cn(iconSize, colorStyles.icon)} />
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -190,115 +122,84 @@ export const DashboardStatCard: React.FC<DashboardStatCardProps> = ({
|
|||||||
valueSuffix,
|
valueSuffix,
|
||||||
subtitle,
|
subtitle,
|
||||||
trend,
|
trend,
|
||||||
icon,
|
|
||||||
iconColor = "blue",
|
|
||||||
size = "default",
|
size = "default",
|
||||||
className,
|
className,
|
||||||
onClick,
|
onClick,
|
||||||
loading = false,
|
loading = false,
|
||||||
tooltip,
|
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
// Size-based styling
|
const valueClass =
|
||||||
const sizeStyles = {
|
size === "large"
|
||||||
default: {
|
? "text-[24px]"
|
||||||
header: CARD_STYLES.header,
|
: size === "compact"
|
||||||
value: TYPOGRAPHY.cardValue,
|
? "text-[17px]"
|
||||||
title: TYPOGRAPHY.cardTitle,
|
: "text-[20px]";
|
||||||
content: CARD_STYLES.content,
|
|
||||||
},
|
|
||||||
compact: {
|
|
||||||
header: CARD_STYLES.headerCompact,
|
|
||||||
value: TYPOGRAPHY.cardValueSmall,
|
|
||||||
title: "text-xs font-medium text-muted-foreground",
|
|
||||||
content: "px-4 pt-0 pb-3",
|
|
||||||
},
|
|
||||||
large: {
|
|
||||||
header: CARD_STYLES.header,
|
|
||||||
value: TYPOGRAPHY.cardValueLarge,
|
|
||||||
title: TYPOGRAPHY.cardTitle,
|
|
||||||
content: CARD_STYLES.contentPadded,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = sizeStyles[size];
|
const cardClass = cn(
|
||||||
const cardClass = onClick ? CARD_STYLES.interactive : CARD_STYLES.base;
|
"rounded-[14px] border bg-card px-3.5 py-3",
|
||||||
|
onClick &&
|
||||||
|
"cursor-pointer transition-colors duration-150 hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||||
|
className
|
||||||
|
);
|
||||||
|
const cardStyle = { borderColor: STUDIO_COLORS.border };
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Card className={cn(cardClass, className)}>
|
<div className={cn(cardClass, "min-h-[92px]")} style={cardStyle}>
|
||||||
<CardHeader className={cn(styles.header, "space-y-0")}>
|
<div className="h-3.5 w-20 animate-pulse rounded bg-muted" />
|
||||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
<div className="mt-2.5 h-6 w-28 animate-pulse rounded bg-muted" />
|
||||||
{icon && <div className="h-8 w-8 bg-muted animate-pulse rounded-lg" />}
|
{subtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-muted/60" />}
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className={styles.content}>
|
|
||||||
<div className="h-8 w-32 bg-muted animate-pulse rounded mb-2" />
|
|
||||||
{subtitle && <div className="h-4 w-20 bg-muted animate-pulse rounded" />}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format the display value with prefix/suffix
|
|
||||||
const formattedValue = (
|
|
||||||
<>
|
|
||||||
{valuePrefix && <span className="">{valuePrefix}</span>}
|
|
||||||
{typeof value === "number" ? value.toLocaleString() : value}
|
|
||||||
{valueSuffix && <span className="">{valueSuffix}</span>}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
className={cn(cardClass, onClick && "cursor-pointer", className)}
|
className={cn(cardClass, "min-h-[92px]")}
|
||||||
|
style={cardStyle}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
onKeyDown={
|
||||||
|
onClick
|
||||||
|
? (event) => {
|
||||||
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
|
event.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
role={onClick ? "button" : undefined}
|
||||||
|
tabIndex={onClick ? 0 : undefined}
|
||||||
>
|
>
|
||||||
<CardHeader className={cn(styles.header, "space-y-0")}>
|
<h4 className="text-[11px] font-semibold" style={{ color: STUDIO_COLORS.muted }}>
|
||||||
<div className="flex items-center gap-1.5">
|
{title}
|
||||||
<CardTitle className={styles.title}>{title}</CardTitle>
|
</h4>
|
||||||
{tooltip && (
|
<div className="mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
<Tooltip>
|
<span
|
||||||
<TooltipTrigger asChild>
|
className={cn(valueClass, "font-bold tracking-tight tabular-nums")}
|
||||||
<button
|
style={{ color: STUDIO_COLORS.ink }}
|
||||||
type="button"
|
>
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
{valuePrefix}
|
||||||
onClick={(e) => e.stopPropagation()}
|
{typeof value === "number" ? value.toLocaleString() : value}
|
||||||
>
|
{valueSuffix}
|
||||||
<Info className="h-3.5 w-3.5" />
|
</span>
|
||||||
</button>
|
{trend && (
|
||||||
</TooltipTrigger>
|
<TrendPill
|
||||||
<TooltipContent side="top" className="max-w-xs">
|
value={trend.value}
|
||||||
<p className="text-sm">{tooltip}</p>
|
label={trend.label}
|
||||||
</TooltipContent>
|
moreIsBetter={trend.moreIsBetter}
|
||||||
</Tooltip>
|
suffix={trend.suffix}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
{icon && <IconContainer icon={icon} color={iconColor} size={size} />}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className={styles.content}>
|
|
||||||
<div className={cn(styles.value, "text-foreground")}>
|
|
||||||
{formattedValue}
|
|
||||||
</div>
|
|
||||||
{(subtitle || trend) && (
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2 mt-1">
|
|
||||||
{subtitle && (
|
|
||||||
<span className={TYPOGRAPHY.cardDescription}>{subtitle}</span>
|
|
||||||
)}
|
|
||||||
{trend && (
|
|
||||||
<TrendIndicator
|
|
||||||
value={trend.value}
|
|
||||||
label={trend.label}
|
|
||||||
moreIsBetter={trend.moreIsBetter}
|
|
||||||
suffix={trend.suffix}
|
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
{children}
|
</div>
|
||||||
</CardContent>
|
{subtitle && (
|
||||||
</Card>
|
<div className="mt-0.5 text-[11.5px]" style={{ color: STUDIO_COLORS.muted }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -314,31 +215,17 @@ export interface DashboardStatCardSkeletonProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const DashboardStatCardSkeleton: React.FC<DashboardStatCardSkeletonProps> = ({
|
export const DashboardStatCardSkeleton: React.FC<DashboardStatCardSkeletonProps> = ({
|
||||||
size = "default",
|
|
||||||
hasIcon = true,
|
|
||||||
hasSubtitle = true,
|
hasSubtitle = true,
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => (
|
||||||
const sizeStyles = {
|
<div
|
||||||
default: { header: CARD_STYLES.header, content: CARD_STYLES.content },
|
className={cn("min-h-[92px] rounded-[14px] border bg-card px-3.5 py-3", className)}
|
||||||
compact: { header: CARD_STYLES.headerCompact, content: "px-4 pt-0 pb-3" },
|
style={{ borderColor: STUDIO_COLORS.border }}
|
||||||
large: { header: CARD_STYLES.header, content: CARD_STYLES.contentPadded },
|
>
|
||||||
};
|
<div className="h-3.5 w-20 animate-pulse rounded bg-muted" />
|
||||||
|
<div className="mt-2.5 h-6 w-28 animate-pulse rounded bg-muted" />
|
||||||
const styles = sizeStyles[size];
|
{hasSubtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-muted/60" />}
|
||||||
|
</div>
|
||||||
return (
|
);
|
||||||
<Card className={cn(CARD_STYLES.base, className)}>
|
|
||||||
<CardHeader className={cn(styles.header, "space-y-0")}>
|
|
||||||
<div className="h-4 w-24 bg-muted animate-pulse rounded" />
|
|
||||||
{hasIcon && <div className="h-8 w-8 bg-muted animate-pulse rounded-lg" />}
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className={styles.content}>
|
|
||||||
<div className="h-8 w-32 bg-muted animate-pulse rounded mb-2" />
|
|
||||||
{hasSubtitle && <div className="h-4 w-20 bg-muted animate-pulse rounded" />}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DashboardStatCard;
|
export default DashboardStatCard;
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ import {
|
|||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ArrowDown, ArrowUp } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
TABLE_STYLES,
|
TABLE_STYLES,
|
||||||
@@ -178,7 +179,7 @@ export function DashboardTable<T extends Record<string, unknown>>({
|
|||||||
sortConfig,
|
sortConfig,
|
||||||
onSort,
|
onSort,
|
||||||
}: DashboardTableProps<T>): React.ReactElement {
|
}: DashboardTableProps<T>): React.ReactElement {
|
||||||
const paddingClass = compact ? "px-3 py-2" : "px-4 py-3";
|
const paddingClass = compact ? "px-3 py-2" : "px-3.5 py-2.5";
|
||||||
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : "";
|
const scrollClass = maxHeight !== "none" ? MAX_HEIGHT_CLASSES[maxHeight] : "";
|
||||||
|
|
||||||
// Handle sort click - toggles direction or sets new sort key
|
// Handle sort click - toggles direction or sets new sort key
|
||||||
@@ -203,11 +204,12 @@ export function DashboardTable<T extends Record<string, unknown>>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant={isActive ? "default" : "ghost"}
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleSortClick(col)}
|
onClick={() => handleSortClick(col)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 font-medium",
|
"h-7 gap-1 rounded px-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground",
|
||||||
|
isActive && "bg-muted text-foreground",
|
||||||
col.align === "center" && "w-full justify-center",
|
col.align === "center" && "w-full justify-center",
|
||||||
col.align === "right" && "w-full justify-end",
|
col.align === "right" && "w-full justify-end",
|
||||||
col.align === "left" && "justify-start",
|
col.align === "left" && "justify-start",
|
||||||
@@ -215,6 +217,11 @@ export function DashboardTable<T extends Record<string, unknown>>({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{col.header}
|
{col.header}
|
||||||
|
{isActive && (
|
||||||
|
sortConfig.direction === "asc"
|
||||||
|
? <ArrowUp className="h-3 w-3" />
|
||||||
|
: <ArrowDown className="h-3 w-3" />
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -222,7 +229,7 @@ export function DashboardTable<T extends Record<string, unknown>>({
|
|||||||
// Loading skeleton
|
// Loading skeleton
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className={cn(scrollClass, className)}>
|
<div className={cn("overflow-hidden rounded-md border border-border/60", scrollClass, className)}>
|
||||||
<Table className={tableClassName}>
|
<Table className={tableClassName}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className={cn(TABLE_STYLES.row, "hover:bg-transparent")}>
|
<TableRow className={cn(TABLE_STYLES.row, "hover:bg-transparent")}>
|
||||||
@@ -288,9 +295,9 @@ export function DashboardTable<T extends Record<string, unknown>>({
|
|||||||
|
|
||||||
// Data table
|
// Data table
|
||||||
return (
|
return (
|
||||||
<div className={cn(scrollClass, className)}>
|
<div className={cn("overflow-hidden", bordered && "rounded-md border border-border/60", scrollClass, className)}>
|
||||||
<Table className={tableClassName}>
|
<Table className={tableClassName}>
|
||||||
<TableHeader className={stickyHeader ? "sticky top-0 bg-background z-10" : ""}>
|
<TableHeader className={stickyHeader ? "sticky top-0 bg-muted/80 backdrop-blur-sm z-10" : ""}>
|
||||||
<TableRow className={cn(TABLE_STYLES.row, TABLE_STYLES.header, "hover:bg-transparent")}>
|
<TableRow className={cn(TABLE_STYLES.row, TABLE_STYLES.header, "hover:bg-transparent")}>
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<TableHead
|
<TableHead
|
||||||
@@ -321,7 +328,7 @@ export function DashboardTable<T extends Record<string, unknown>>({
|
|||||||
TABLE_STYLES.rowHover,
|
TABLE_STYLES.rowHover,
|
||||||
onRowClick && "cursor-pointer",
|
onRowClick && "cursor-pointer",
|
||||||
striped && rowIndex % 2 === 1 && "bg-muted/30",
|
striped && rowIndex % 2 === 1 && "bg-muted/30",
|
||||||
bordered && "border-b border-border/50"
|
bordered && "border-b border-border/40 last:border-b-0"
|
||||||
)}
|
)}
|
||||||
onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
|
onClick={onRowClick ? () => onRowClick(row, rowIndex) : undefined}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* StudioControls — shared Sorbet Studio control atoms.
|
||||||
|
*
|
||||||
|
* MetricPill: series toggle whose color dot doubles as the chart legend
|
||||||
|
* (charts using these should NOT also render a recharts <Legend/>).
|
||||||
|
* LegendChip: non-interactive series chip for charts without toggles.
|
||||||
|
* QuietStat: deemphasized stat cell for gap-px strips (secondary metrics).
|
||||||
|
* PILL_TRIGGER_CLASS / PILL_BUTTON_CLASS: class strings that make ad-hoc
|
||||||
|
* Select triggers and small buttons match BusinessRangeSelect's pill look.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export const PILL_TRIGGER_CLASS =
|
||||||
|
"h-8 w-auto gap-1.5 rounded-full border-border bg-card px-3.5 text-xs font-semibold text-foreground shadow-none hover:bg-surface-subtle focus:ring-1 focus:ring-coral/30 focus:ring-offset-0";
|
||||||
|
|
||||||
|
export const PILL_BUTTON_CLASS =
|
||||||
|
"h-8 rounded-full border-border bg-card px-3.5 text-xs font-semibold text-foreground shadow-none hover:bg-surface-subtle";
|
||||||
|
|
||||||
|
export interface MetricPillProps {
|
||||||
|
active: boolean;
|
||||||
|
color: string;
|
||||||
|
/** Dashed ring dot for comparison/average series */
|
||||||
|
dashed?: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetricPill: React.FC<MetricPillProps> = ({
|
||||||
|
active,
|
||||||
|
color,
|
||||||
|
dashed = false,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
|
||||||
|
active
|
||||||
|
? "bg-muted text-foreground hover:bg-[#eae7e2]"
|
||||||
|
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={
|
||||||
|
dashed
|
||||||
|
? { background: "transparent", border: `1.5px dashed ${active ? color : "#d8d4cd"}` }
|
||||||
|
: { background: active ? color : "#d8d4cd" }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface LegendChipProps {
|
||||||
|
color: string;
|
||||||
|
dashed?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Static series chip — for charts whose series aren't toggleable */
|
||||||
|
export const LegendChip: React.FC<LegendChipProps> = ({ color, dashed = false, children }) => (
|
||||||
|
<span className="flex h-7 shrink-0 items-center gap-1.5 px-2 text-xs font-medium text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className="inline-block h-2 w-2 rounded-full"
|
||||||
|
style={
|
||||||
|
dashed
|
||||||
|
? { background: "transparent", border: `1.5px dashed ${color}` }
|
||||||
|
: { background: color }
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
export interface QuietStatProps {
|
||||||
|
label: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
sub?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deemphasized stat cell. Place inside:
|
||||||
|
* <div className="grid grid-cols-2 gap-px overflow-hidden rounded-[14px]
|
||||||
|
* border border-border bg-muted sm:grid-cols-3 lg:grid-cols-6">
|
||||||
|
*/
|
||||||
|
export const QuietStat: React.FC<QuietStatProps> = ({ label, value, sub, onClick, loading }) => {
|
||||||
|
const Tag = onClick ? "button" : "div";
|
||||||
|
return (
|
||||||
|
<Tag
|
||||||
|
{...(onClick ? { type: "button" as const, onClick } : {})}
|
||||||
|
className={cn(
|
||||||
|
"bg-card px-3.5 py-2.5 text-left",
|
||||||
|
onClick &&
|
||||||
|
"transition-colors hover:bg-surface-subtle focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="text-[10.5px] font-semibold text-muted-foreground/75">{label}</div>
|
||||||
|
{loading ? (
|
||||||
|
<div className="mt-1.5 h-4 w-16 animate-pulse rounded bg-muted" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-[15px] font-bold tabular-nums tracking-tight text-foreground">
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{sub && <div className="text-[10.5px] text-muted-foreground">{sub}</div>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const QUIET_STRIP_CLASS =
|
||||||
|
"grid gap-px overflow-hidden rounded-[14px] border border-border bg-muted";
|
||||||
@@ -80,13 +80,24 @@ export {
|
|||||||
// STAT CARDS
|
// STAT CARDS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
export {
|
||||||
|
MetricPill,
|
||||||
|
LegendChip,
|
||||||
|
QuietStat,
|
||||||
|
PILL_TRIGGER_CLASS,
|
||||||
|
PILL_BUTTON_CLASS,
|
||||||
|
QUIET_STRIP_CLASS,
|
||||||
|
type MetricPillProps,
|
||||||
|
type QuietStatProps,
|
||||||
|
} from "./StudioControls";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DashboardStatCard,
|
DashboardStatCard,
|
||||||
DashboardStatCardSkeleton,
|
DashboardStatCardSkeleton,
|
||||||
|
TrendPill,
|
||||||
type DashboardStatCardProps,
|
type DashboardStatCardProps,
|
||||||
type TrendDirection,
|
type TrendDirection,
|
||||||
type CardSize,
|
type CardSize,
|
||||||
type IconColor,
|
|
||||||
type TrendProps,
|
type TrendProps,
|
||||||
type DashboardStatCardSkeletonProps,
|
type DashboardStatCardSkeletonProps,
|
||||||
} from "./DashboardStatCard";
|
} from "./DashboardStatCard";
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/**
|
||||||
|
* Sparkline — tiny inline trend line for stat tiles (Sorbet Studio).
|
||||||
|
*
|
||||||
|
* Pure SVG, no axes or labels: it shows shape, not values (the tile's number
|
||||||
|
* carries the value). Stretches to its container via preserveAspectRatio=none,
|
||||||
|
* so strokes use vector-effect to stay crisp.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface SparklineProps {
|
||||||
|
data: number[];
|
||||||
|
color: string;
|
||||||
|
/** rgba fill for an area under the line (featured tiles) */
|
||||||
|
fill?: string;
|
||||||
|
/** Tooltip text, e.g. "Last 30 days" */
|
||||||
|
title?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const W = 100;
|
||||||
|
const H = 32;
|
||||||
|
const PAD = 3;
|
||||||
|
|
||||||
|
const Sparkline: React.FC<SparklineProps> = ({ data, color, fill, title, className }) => {
|
||||||
|
if (!data || data.length < 2) return null;
|
||||||
|
|
||||||
|
const max = Math.max(...data);
|
||||||
|
const min = Math.min(...data);
|
||||||
|
const span = max - min || 1;
|
||||||
|
const pts = data.map((v, i) => [
|
||||||
|
PAD + (i * (W - 2 * PAD)) / (data.length - 1),
|
||||||
|
PAD + ((max - v) * (H - 2 * PAD)) / span,
|
||||||
|
]);
|
||||||
|
const d = pts
|
||||||
|
.map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`)
|
||||||
|
.join(" ");
|
||||||
|
const [ex, ey] = pts[pts.length - 1];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${W} ${H}`}
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
className={className}
|
||||||
|
style={{ display: "block", width: "100%", height: "100%" }}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{title && <title>{title}</title>}
|
||||||
|
{fill && (
|
||||||
|
<path
|
||||||
|
d={`${d} L${ex.toFixed(1)} ${H - PAD} L${PAD} ${H - PAD} Z`}
|
||||||
|
fill={fill}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<path
|
||||||
|
d={d}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeLinecap="round"
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
<circle cx={ex.toFixed(1)} cy={ey.toFixed(1)} r={2.4} fill={color} />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Sparkline;
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* StudioStatTile — pastel hero stat tile (Sorbet Studio).
|
||||||
|
*
|
||||||
|
* The tile's pastel fill + matching sparkline tone are that metric's identity;
|
||||||
|
* up/down semantics live only in the delta pill's text color, never the fill.
|
||||||
|
* Drop-in beside DashboardStatCard: same title/value/subtitle/trend/onClick
|
||||||
|
* contract, plus a `tone` and optional `spark` series.
|
||||||
|
*/
|
||||||
|
import React from "react";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { STUDIO_COLORS, STUDIO_TILES } from "@/lib/dashboard/designTokens";
|
||||||
|
import Sparkline from "./Sparkline";
|
||||||
|
|
||||||
|
interface StudioStatTileProps {
|
||||||
|
title: string;
|
||||||
|
value: React.ReactNode;
|
||||||
|
valuePrefix?: string;
|
||||||
|
valueSuffix?: string;
|
||||||
|
subtitle?: React.ReactNode;
|
||||||
|
trend?: { value: number; moreIsBetter?: boolean };
|
||||||
|
tone: keyof typeof STUDIO_TILES;
|
||||||
|
/** Featured tile: larger numeral + area-filled sparkline */
|
||||||
|
featured?: boolean;
|
||||||
|
spark?: number[];
|
||||||
|
sparkTitle?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudioStatTile: React.FC<StudioStatTileProps> = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
valuePrefix,
|
||||||
|
valueSuffix,
|
||||||
|
subtitle,
|
||||||
|
trend,
|
||||||
|
tone,
|
||||||
|
featured = false,
|
||||||
|
spark,
|
||||||
|
sparkTitle = "Last 30 days",
|
||||||
|
onClick,
|
||||||
|
loading = false,
|
||||||
|
}) => {
|
||||||
|
const t = STUDIO_TILES[tone];
|
||||||
|
|
||||||
|
const showTrend = trend != null && Math.abs(trend.value) >= 0.1;
|
||||||
|
const isGood = trend
|
||||||
|
? (trend.moreIsBetter ?? true) === trend.value > 0
|
||||||
|
: true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"rounded-[14px] px-3.5 py-3 text-left transition-all",
|
||||||
|
onClick && "cursor-pointer hover:brightness-[1.03] hover:shadow-sm"
|
||||||
|
)}
|
||||||
|
style={{ background: t.fill, color: STUDIO_COLORS.ink }}
|
||||||
|
onClick={onClick}
|
||||||
|
role={onClick ? "button" : undefined}
|
||||||
|
tabIndex={onClick ? 0 : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
onClick
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
|
e.preventDefault();
|
||||||
|
onClick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="text-[11px] font-semibold" style={{ color: "rgba(43,41,37,.62)" }}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Skeleton className="mt-1.5 h-7 w-24 bg-white/60" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-32 bg-white/50" />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="mt-0.5 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"font-bold tracking-tight tabular-nums",
|
||||||
|
featured ? "text-[26px]" : "text-[22px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{valuePrefix}
|
||||||
|
{value}
|
||||||
|
{valueSuffix}
|
||||||
|
</span>
|
||||||
|
{showTrend && (
|
||||||
|
<span
|
||||||
|
className="rounded-full px-2 py-0.5 text-[10.5px] font-bold"
|
||||||
|
style={{
|
||||||
|
background: "rgba(255,255,255,.78)",
|
||||||
|
color: isGood ? STUDIO_COLORS.up : STUDIO_COLORS.down,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{trend.value > 0 ? "↑" : "↓"} {Math.abs(trend.value).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div className="mt-0.5 text-[11.5px]" style={{ color: "rgba(43,41,37,.6)" }}>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{spark && spark.length > 1 && !loading && (
|
||||||
|
<div className={cn("mt-2", featured ? "h-9" : "h-7")}>
|
||||||
|
<Sparkline
|
||||||
|
data={spark}
|
||||||
|
color={t.spark}
|
||||||
|
fill={featured ? t.sparkFill : undefined}
|
||||||
|
title={sparkTitle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudioStatTile;
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import { Moon, Sun } from "lucide-react"
|
|
||||||
import { useTheme } from "@/components/dashboard/theme/ThemeProvider"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export function ModeToggle() {
|
|
||||||
const { theme, setTheme } = useTheme()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
|
||||||
className="w-9 h-9 rounded-md border-none bg-transparent hover:bg-transparent"
|
|
||||||
>
|
|
||||||
<div className="relative w-5 h-5">
|
|
||||||
<Sun
|
|
||||||
className="absolute inset-0 h-full w-full transition-all duration-300 text-yellow-500 dark:rotate-0 dark:scale-0 dark:opacity-0 rotate-0 scale-100 opacity-100"
|
|
||||||
/>
|
|
||||||
<Moon
|
|
||||||
className="absolute inset-0 h-full w-full transition-all duration-300 text-slate-900 dark:text-slate-200 rotate-90 scale-0 opacity-0 dark:rotate-0 dark:scale-100 dark:opacity-100"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="sr-only">Toggle theme</span>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -232,7 +232,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
"sticky left-0 z-10 border-r px-3 py-2",
|
"sticky left-0 z-10 border-r px-3 py-2",
|
||||||
groupStyle.rowClass,
|
groupStyle.rowClass,
|
||||||
isImportant ? "font-semibold text-emerald-800" : "font-medium text-slate-700"
|
isImportant ? "font-semibold text-emerald-800" : "font-medium text-foreground/90"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -243,7 +243,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-sm leading-tight pr-2",
|
"text-sm leading-tight pr-2",
|
||||||
isImportant ? "font-semibold text-emerald-900" : "font-medium text-slate-800"
|
isImportant ? "font-semibold text-emerald-900" : "font-medium text-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{row.label}
|
{row.label}
|
||||||
@@ -275,7 +275,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
|||||||
<TableCell
|
<TableCell
|
||||||
key={`${row.key}-${bucket.key}`}
|
key={`${row.key}-${bucket.key}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-center whitespace-nowrap px-1 py-1 text-slate-700",
|
"text-center whitespace-nowrap px-1 py-1 text-foreground/90",
|
||||||
groupStyle.rowClass,
|
groupStyle.rowClass,
|
||||||
isImportant && "font-semibold text-emerald-700"
|
isImportant && "font-semibold text-emerald-700"
|
||||||
)}
|
)}
|
||||||
@@ -294,7 +294,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
|||||||
<TableCell
|
<TableCell
|
||||||
key={`${row.key}-${bucket.key}`}
|
key={`${row.key}-${bucket.key}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-center whitespace-nowrap px-1 py-1 text-slate-700",
|
"text-center whitespace-nowrap px-1 py-1 text-foreground/90",
|
||||||
groupStyle.rowClass,
|
groupStyle.rowClass,
|
||||||
isImportant && "font-semibold text-emerald-700"
|
isImportant && "font-semibold text-emerald-700"
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,19 +1,31 @@
|
|||||||
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
import { SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
||||||
import { AppSidebar } from "./AppSidebar";
|
import { AppSidebar } from "./AppSidebar";
|
||||||
import { Outlet } from "react-router-dom";
|
import { Outlet, useLocation } from "react-router-dom";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export function MainLayout() {
|
export function MainLayout() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
// The dashboard paints its own warm full-bleed background and scrolls in its
|
||||||
|
// own container, so it escapes the 1500px content cap (and the pr-2 gutter)
|
||||||
|
// to reach the window edge. Other pages keep the capped white layout.
|
||||||
|
const isDashboard = pathname.startsWith("/dashboard");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div layout>
|
<motion.div layout>
|
||||||
<SidebarProvider defaultOpen>
|
<SidebarProvider defaultOpen>
|
||||||
<div className="flex min-h-screen w-full pr-2">
|
<div className={cn("flex min-h-screen w-full", !isDashboard && "pr-2")}>
|
||||||
<AppSidebar />
|
<AppSidebar />
|
||||||
<main className="flex-1 overflow-hidden">
|
<main className="flex-1 overflow-hidden">
|
||||||
<div className="flex h-14 w-full items-center border-b px-4 gap-4">
|
<div className="flex h-14 w-full items-center border-b px-4 gap-4">
|
||||||
<SidebarTrigger />
|
<SidebarTrigger />
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-auto h-[calc(100vh-3.5rem)] max-w-[1500px]">
|
<div
|
||||||
|
className={cn(
|
||||||
|
"overflow-auto h-[calc(100vh-3.5rem)]",
|
||||||
|
!isDashboard && "max-w-[1500px]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
@@ -62,17 +63,18 @@ export function BestSellers() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Tabs defaultValue="products">
|
<Tabs defaultValue="products">
|
||||||
<CardHeader>
|
<SectionHeader
|
||||||
<div className="flex flex-row items-center justify-between">
|
title="Best Sellers"
|
||||||
<CardTitle className="text-lg font-medium">Best Sellers</CardTitle>
|
size="large"
|
||||||
|
actions={
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="products">Products</TabsTrigger>
|
<TabsTrigger value="products">Products</TabsTrigger>
|
||||||
<TabsTrigger value="brands">Brands</TabsTrigger>
|
<TabsTrigger value="brands">Brands</TabsTrigger>
|
||||||
<TabsTrigger value="categories">Categories</TabsTrigger>
|
<TabsTrigger value="categories">Categories</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent>
|
<CardContent className="pt-4">
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">Failed to load best sellers</p>
|
<p className="text-sm text-destructive">Failed to load best sellers</p>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
|
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
@@ -87,9 +88,10 @@ export function ForecastMetrics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
<SectionHeader
|
||||||
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
|
title="Forecast"
|
||||||
<div className="flex items-center gap-2">
|
size="large"
|
||||||
|
actions={
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||||
@@ -100,6 +102,8 @@ export function ForecastMetrics() {
|
|||||||
<ForecastAccuracy />
|
<ForecastAccuracy />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
}
|
||||||
|
timeSelector={
|
||||||
<Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}>
|
<Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="30">30D</TabsTrigger>
|
<TabsTrigger value="30">30D</TabsTrigger>
|
||||||
@@ -107,9 +111,9 @@ export function ForecastMetrics() {
|
|||||||
<TabsTrigger value="year">EOY</TabsTrigger>
|
<TabsTrigger value="year">EOY</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
}
|
||||||
</CardHeader>
|
/>
|
||||||
<CardContent className="py-0 -mb-2">
|
<CardContent className="pt-3 pb-0 -mb-2">
|
||||||
{error ? (
|
{error ? (
|
||||||
<div className="text-sm text-red-500">Error: {error.message}</div>
|
<div className="text-sm text-red-500">Error: {error.message}</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/utils/formatCurrency"
|
import { formatCurrency } from "@/utils/formatCurrency"
|
||||||
import { AlertTriangle, Layers, DollarSign, Tag } from "lucide-react"
|
import { AlertTriangle, Layers, DollarSign, Tag } from "lucide-react"
|
||||||
@@ -47,10 +48,8 @@ export function OverstockMetrics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<SectionHeader title="Overstock" size="large" />
|
||||||
<CardTitle className="text-xl font-medium">Overstock</CardTitle>
|
<CardContent className="pt-4">
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">Failed to load overstock metrics</p>
|
<p className="text-sm text-destructive">Failed to load overstock metrics</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/utils/formatCurrency"
|
import { formatCurrency } from "@/utils/formatCurrency"
|
||||||
@@ -84,10 +85,8 @@ export function PurchaseMetrics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<SectionHeader title="Purchases" size="large" />
|
||||||
<CardTitle className="text-xl font-medium">Purchases</CardTitle>
|
<CardContent className="pt-4">
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">Failed to load purchase metrics</p>
|
<p className="text-sm text-destructive">Failed to load purchase metrics</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/utils/formatCurrency"
|
import { formatCurrency } from "@/utils/formatCurrency"
|
||||||
import { PackagePlus, DollarSign, Tag } from "lucide-react"
|
import { PackagePlus, DollarSign, Tag } from "lucide-react"
|
||||||
@@ -49,10 +50,8 @@ export function ReplenishmentMetrics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<SectionHeader title="Replenishment" size="large" />
|
||||||
<CardTitle className="text-xl font-medium">Replenishment</CardTitle>
|
<CardContent className="pt-4">
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">Failed to load replenishment metrics</p>
|
<p className="text-sm text-destructive">Failed to load replenishment metrics</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
|
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
@@ -72,11 +73,14 @@ export function SalesMetrics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
<SectionHeader
|
||||||
<CardTitle className="text-xl font-medium">Sales</CardTitle>
|
title="Sales"
|
||||||
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} />
|
size="large"
|
||||||
</CardHeader>
|
timeSelector={
|
||||||
<CardContent className="py-0 -mb-2">
|
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CardContent className="pt-3 pb-0 -mb-2">
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">Failed to load sales metrics</p>
|
<p className="text-sm text-destructive">Failed to load sales metrics</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
import { formatCurrency } from "@/utils/formatCurrency"
|
import { formatCurrency } from "@/utils/formatCurrency"
|
||||||
@@ -116,10 +117,8 @@ export function StockMetrics() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<SectionHeader title="Stock" size="large" />
|
||||||
<CardTitle className="text-xl font-medium">Stock</CardTitle>
|
<CardContent className="pt-4">
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">Failed to load stock metrics</p>
|
<p className="text-sm text-destructive">Failed to load stock metrics</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
@@ -38,10 +39,8 @@ export function TopOverstockedProducts() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<SectionHeader title="Top Overstocked Products" size="large" />
|
||||||
<CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle>
|
<CardContent className="pt-4">
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">Failed to load overstocked products</p>
|
<p className="text-sm text-destructive">Failed to load overstocked products</p>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from "@tanstack/react-query"
|
import { useQuery } from "@tanstack/react-query"
|
||||||
import { apiFetch } from '@/utils/api';
|
import { apiFetch } from '@/utils/api';
|
||||||
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card"
|
import { CardContent } from "@/components/ui/card"
|
||||||
|
import { SectionHeader } from "@/components/shared"
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import config from "@/config"
|
import config from "@/config"
|
||||||
@@ -38,10 +39,8 @@ export function TopReplenishProducts() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CardHeader>
|
<SectionHeader title="Top Products To Replenish" size="large" />
|
||||||
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
|
<CardContent className="pt-4">
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isError ? (
|
{isError ? (
|
||||||
<p className="text-sm text-destructive">Failed to load replenish products</p>
|
<p className="text-sm text-destructive">Failed to load replenish products</p>
|
||||||
) : isLoading ? (
|
) : isLoading ? (
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export function EditableMultiSelect({
|
|||||||
{/* Selected items pinned to top */}
|
{/* Selected items pinned to top */}
|
||||||
{value.length > 0 && (
|
{value.length > 0 && (
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50/80 dark:bg-green-950/40 border-b border-green-100 dark:border-green-900">
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 bg-green-50/80 border-b border-green-100">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
<span>Selected ({value.length})</span>
|
<span>Selected ({value.length})</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +204,7 @@ export function EditableMultiSelect({
|
|||||||
key={`sel-${selectedVal}`}
|
key={`sel-${selectedVal}`}
|
||||||
value={`sel-${opt?.label ?? selectedVal}`}
|
value={`sel-${opt?.label ?? selectedVal}`}
|
||||||
onSelect={() => handleSelect(selectedVal)}
|
onSelect={() => handleSelect(selectedVal)}
|
||||||
className="bg-green-50/50 dark:bg-green-950/30"
|
className="bg-green-50/50"
|
||||||
>
|
>
|
||||||
<Check className="mr-2 h-4 w-4 opacity-100 text-green-600" />
|
<Check className="mr-2 h-4 w-4 opacity-100 text-green-600" />
|
||||||
{hex && (
|
{hex && (
|
||||||
@@ -226,7 +226,7 @@ export function EditableMultiSelect({
|
|||||||
{/* AI Suggestions section */}
|
{/* AI Suggestions section */}
|
||||||
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
|
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 bg-purple-50/80 border-b border-purple-100">
|
||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles className="h-3 w-3" />
|
||||||
<span>Suggested</span>
|
<span>Suggested</span>
|
||||||
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
|
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
@@ -242,7 +242,7 @@ export function EditableMultiSelect({
|
|||||||
key={`suggestion-${suggestion.id}`}
|
key={`suggestion-${suggestion.id}`}
|
||||||
value={`suggestion-${suggestion.name}`}
|
value={`suggestion-${suggestion.name}`}
|
||||||
onSelect={() => handleSelect(String(suggestion.id))}
|
onSelect={() => handleSelect(String(suggestion.id))}
|
||||||
className="bg-purple-50/30 dark:bg-purple-950/20"
|
className="bg-purple-50/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
||||||
@@ -259,7 +259,7 @@ export function EditableMultiSelect({
|
|||||||
{showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
{showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0">
|
<span className="text-xs text-purple-500 ml-2 flex-shrink-0">
|
||||||
{similarityPercent}%
|
{similarityPercent}%
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
@@ -703,7 +703,7 @@ export function ProductEditForm({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDescDialogOpen(true)}
|
onClick={() => setDescDialogOpen(true)}
|
||||||
className="flex items-center gap-1 px-1.5 -mr-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors shrink-0"
|
className="flex items-center gap-1 px-1.5 -mr-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 text-purple-600 text-xs transition-colors shrink-0"
|
||||||
title="View AI suggestion"
|
title="View AI suggestion"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-3.5 w-3.5" />
|
<Sparkles className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export function ProductSearch({
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search products…"
|
placeholder="Search products…"
|
||||||
|
className="rounded-full px-3.5"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
@@ -185,11 +186,11 @@ export function ProductSearch({
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead className="sticky top-0 bg-background">Name</TableHead>
|
<TableHead className="sticky top-0 bg-card">Name</TableHead>
|
||||||
<TableHead className="sticky top-0 bg-background">Item Number</TableHead>
|
<TableHead className="sticky top-0 bg-card">Item Number</TableHead>
|
||||||
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
|
<TableHead className="sticky top-0 bg-card">Brand</TableHead>
|
||||||
<TableHead className="sticky top-0 bg-background">Line</TableHead>
|
<TableHead className="sticky top-0 bg-card">Line</TableHead>
|
||||||
<TableHead className="sticky top-0 bg-background text-right">
|
<TableHead className="sticky top-0 bg-card text-right">
|
||||||
Price
|
Price
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|||||||
@@ -229,8 +229,8 @@ export const BASE_IMPORT_FIELDS = [
|
|||||||
{
|
{
|
||||||
label: "Weight",
|
label: "Weight",
|
||||||
key: "weight",
|
key: "weight",
|
||||||
description: "Product weight (in lbs)",
|
description: "Product weight (in oz — convert lbs ×16, kg ×35.274)",
|
||||||
alternateMatches: ["weight (lbs.)"],
|
alternateMatches: ["weight (lbs.)", "weight (oz)", "weight (oz.)"],
|
||||||
fieldType: { type: "input" },
|
fieldType: { type: "input" },
|
||||||
width: 100,
|
width: 100,
|
||||||
validations: [
|
validations: [
|
||||||
|
|||||||
+1
-1
@@ -70,7 +70,7 @@ export const GenericDropzone = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onShowUnassigned();
|
onShowUnassigned();
|
||||||
}}
|
}}
|
||||||
className="mt-2 text-amber-600 dark:text-amber-400"
|
className="mt-2 text-amber-600"
|
||||||
>
|
>
|
||||||
Show {unassignedImages.length} unassigned {unassignedImages.length === 1 ? 'image' : 'images'}
|
Show {unassignedImages.length} unassigned {unassignedImages.length === 1 ? 'image' : 'images'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+2
-2
@@ -43,8 +43,8 @@ export const CopyButton = ({ text }: CopyButtonProps) => {
|
|||||||
className={`ml-1 inline-flex items-center justify-center rounded-full p-1 transition-colors ${
|
className={`ml-1 inline-flex items-center justify-center rounded-full p-1 transition-colors ${
|
||||||
canCopy
|
canCopy
|
||||||
? isCopied
|
? isCopied
|
||||||
? "bg-green-100 text-green-600 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400"
|
? "bg-green-100 text-green-600 hover:bg-green-200"
|
||||||
: "text-muted-foreground hover:bg-gray-100 dark:hover:bg-gray-800"
|
: "text-muted-foreground hover:bg-gray-100"
|
||||||
: "opacity-50 cursor-not-allowed"
|
: "opacity-50 cursor-not-allowed"
|
||||||
}`}
|
}`}
|
||||||
disabled={!canCopy}
|
disabled={!canCopy}
|
||||||
|
|||||||
+1
-1
@@ -201,7 +201,7 @@ export const SortableImage = ({
|
|||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
|
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white">
|
||||||
<img
|
<img
|
||||||
src={getFullImageUrl(image.url || image.imageUrl || '')}
|
src={getFullImageUrl(image.url || image.imageUrl || '')}
|
||||||
alt={`${displayName} - Image ${imgIndex + 1}`}
|
alt={`${displayName} - Image ${imgIndex + 1}`}
|
||||||
|
|||||||
+2
-2
@@ -24,11 +24,11 @@ export const UnassignedImagesSection = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-4 px-4">
|
<div className="mb-4 px-4">
|
||||||
<div className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-md p-3">
|
<div className="bg-amber-50 border border-amber-200 rounded-md p-3">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-amber-500" />
|
<AlertCircle className="h-5 w-5 text-amber-500" />
|
||||||
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-400">
|
<h3 className="text-sm font-medium text-amber-700">
|
||||||
Unassigned Images ({unassignedImages.length})
|
Unassigned Images ({unassignedImages.length})
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -43,7 +43,7 @@ export const UnassignedImageItem = ({
|
|||||||
<p className="text-white text-xs mb-1 truncate">{image.file.name}</p>
|
<p className="text-white text-xs mb-1 truncate">{image.file.name}</p>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select onValueChange={(value) => onAssign(index, parseInt(value))}>
|
<Select onValueChange={(value) => onAssign(index, parseInt(value))}>
|
||||||
<SelectTrigger className="h-7 text-xs bg-white dark:bg-gray-800 border-0 text-black dark:text-white">
|
<SelectTrigger className="h-7 text-xs bg-white border-0 text-black">
|
||||||
<SelectValue placeholder="Assign to..." />
|
<SelectValue placeholder="Assign to..." />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -57,7 +57,7 @@ export const UnassignedImageItem = ({
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 bg-white dark:bg-gray-800 text-black dark:text-white"
|
className="h-7 w-7 bg-white text-black"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onRemove(index);
|
onRemove(index);
|
||||||
@@ -94,7 +94,7 @@ export const UnassignedImageItem = ({
|
|||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black">
|
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white">
|
||||||
<img
|
<img
|
||||||
src={image.previewUrl}
|
src={image.previewUrl}
|
||||||
alt={`Unassigned image: ${image.file.name}`}
|
alt={`Unassigned image: ${image.file.name}`}
|
||||||
|
|||||||
@@ -1510,7 +1510,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={!!globalSelections.costIsTotalCost}
|
checked={!!globalSelections.costIsTotalCost}
|
||||||
onChange={(e) => setGlobalSelections(prev => ({ ...prev, costIsTotalCost: e.target.checked }))}
|
onChange={(e) => setGlobalSelections(prev => ({ ...prev, costIsTotalCost: e.target.checked }))}
|
||||||
className="h-3.5 w-3.5 rounded border-gray-300"
|
className="h-3.5 w-3.5 rounded border-input"
|
||||||
/>
|
/>
|
||||||
Divide by min qty
|
Divide by min qty
|
||||||
</label>
|
</label>
|
||||||
@@ -1561,7 +1561,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`unmapped-values-${column.index}`}>
|
<React.Fragment key={`unmapped-values-${column.index}`}>
|
||||||
<TableRow className="bg-amber-50 dark:bg-amber-950/20">
|
<TableRow className="bg-amber-50">
|
||||||
<TableCell className="font-medium">{column.header}</TableCell>
|
<TableCell className="font-medium">{column.header}</TableCell>
|
||||||
<TableCell className="text-center">
|
<TableCell className="text-center">
|
||||||
{renderSamplePreview(column.index)}
|
{renderSamplePreview(column.index)}
|
||||||
@@ -1944,7 +1944,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
|||||||
) : (
|
) : (
|
||||||
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0" />
|
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className={`text-sm ${isAccountedFor ? "text-green-700 dark:text-green-400" : "text-amber-700 dark:text-amber-400"}`}>
|
<span className={`text-sm ${isAccountedFor ? "text-green-700" : "text-amber-700"}`}>
|
||||||
{getFieldLabel(field.key)}
|
{getFieldLabel(field.key)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground ml-auto">
|
<span className="text-xs text-muted-foreground ml-auto">
|
||||||
|
|||||||
+2
-2
@@ -77,8 +77,8 @@ export const TemplateColumn = <T extends string>({ column, onChange, onSubChange
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{isChecked && (
|
{isChecked && (
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-green-700 bg-green-300 dark:bg-green-900/20">
|
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-green-700 bg-green-300">
|
||||||
<Check className="h-4 w-4 text-green-700 dark:text-green-500" />
|
<Check className="h-4 w-4 text-green-700" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { SelectHeaderTable } from "./components/SelectHeaderTable"
|
|||||||
import { useRsi } from "../../hooks/useRsi"
|
import { useRsi } from "../../hooks/useRsi"
|
||||||
import type { RawData } from "../../types"
|
import type { RawData } from "../../types"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { toast } from "sonner"
|
||||||
|
|
||||||
type SelectHeaderProps = {
|
type SelectHeaderProps = {
|
||||||
data: RawData[]
|
data: RawData[]
|
||||||
@@ -24,7 +24,6 @@ const isRowCompletelyEmpty = (row: RawData): boolean => {
|
|||||||
|
|
||||||
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
|
export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps) => {
|
||||||
const { translations } = useRsi()
|
const { translations } = useRsi()
|
||||||
const { toast } = useToast()
|
|
||||||
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
|
const [selectedRows, setSelectedRows] = useState<ReadonlySet<number>>(new Set([0]))
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
@@ -133,19 +132,15 @@ export const SelectHeaderStep = ({ data, onContinue, onBack }: SelectHeaderProps
|
|||||||
setLocalData(filteredRows);
|
setLocalData(filteredRows);
|
||||||
setSelectedRows(new Set([newSelectedIndex]));
|
setSelectedRows(new Set([newSelectedIndex]));
|
||||||
|
|
||||||
toast({
|
toast.success("Rows removed", {
|
||||||
title: "Rows removed",
|
|
||||||
description: `Removed ${localData.length - filteredRows.length} empty, single-value, or duplicate rows`,
|
description: `Removed ${localData.length - filteredRows.length} empty, single-value, or duplicate rows`,
|
||||||
variant: "default"
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast({
|
toast.info("No rows removed", {
|
||||||
title: "No rows removed",
|
|
||||||
description: "No empty, single-value, or duplicate rows were found",
|
description: "No empty, single-value, or duplicate rows were found",
|
||||||
variant: "default"
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [localData, selectedRows, toast]);
|
}, [localData, selectedRows]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-[calc(100vh-9.5rem)]">
|
<div className="flex flex-col h-[calc(100vh-9.5rem)]">
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
|
|||||||
import { useRsi } from "../hooks/useRsi"
|
import { useRsi } from "../hooks/useRsi"
|
||||||
import type { RawData, Data } from "../types"
|
import type { RawData, Data } from "../types"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { toast } from "sonner"
|
||||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||||
import { computeMappingSignature, type MappingSignature } from "./ValidationStep/utils/mappingSignature"
|
import { computeMappingSignature, type MappingSignature } from "./ValidationStep/utils/mappingSignature"
|
||||||
import { useValidationStore } from "./ValidationStep/store/validationStore"
|
import { useValidationStore } from "./ValidationStep/store/validationStore"
|
||||||
@@ -88,7 +88,6 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
tableHook,
|
tableHook,
|
||||||
onSubmit } = useRsi()
|
onSubmit } = useRsi()
|
||||||
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
const [uploadedFile, setUploadedFile] = useState<File | null>(null)
|
||||||
const { toast } = useToast()
|
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const resetValidationStore = useValidationStore((state) => state.reset)
|
const resetValidationStore = useValidationStore((state) => state.reset)
|
||||||
|
|
||||||
@@ -116,13 +115,9 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
|||||||
}, [queryClient, resetValidationStore]);
|
}, [queryClient, resetValidationStore]);
|
||||||
const errorToast = useCallback(
|
const errorToast = useCallback(
|
||||||
(description: string) => {
|
(description: string) => {
|
||||||
toast({
|
toast.error(translations.alerts.toast.error, { description })
|
||||||
variant: "destructive",
|
|
||||||
title: translations.alerts.toast.error,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
[toast, translations],
|
[translations],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Keep track of global selections across steps
|
// Keep track of global selections across steps
|
||||||
|
|||||||
@@ -6,14 +6,6 @@ import { StepType } from "../UploadFlow"
|
|||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { AuthContext } from "@/contexts/AuthContext"
|
import { AuthContext } from "@/contexts/AuthContext"
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from "@/components/ui/label"
|
||||||
import { Bug } from "lucide-react"
|
import { Bug } from "lucide-react"
|
||||||
@@ -27,17 +19,23 @@ type UploadProps = {
|
|||||||
onRestoreSession?: (session: ImportSession) => void
|
onRestoreSession?: (session: ImportSession) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OrSeparator = () => (
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<Separator className="w-24" />
|
||||||
|
<span className="px-3 text-muted-foreground text-sm font-medium">OR</span>
|
||||||
|
<Separator className="w-24" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => {
|
export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: UploadProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
const { translations } = useRsi()
|
const { translations } = useRsi()
|
||||||
const { user } = useContext(AuthContext)
|
const { user } = useContext(AuthContext)
|
||||||
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"))
|
const hasDebugPermission = Boolean(user?.is_admin || user?.permissions?.includes("admin:debug"))
|
||||||
|
|
||||||
// Debug import state
|
const [jsonInput, setJsonInput] = useState("")
|
||||||
const [debugDialogOpen, setDebugDialogOpen] = useState(false)
|
const [jsonError, setJsonError] = useState<string | null>(null)
|
||||||
const [debugJsonInput, setDebugJsonInput] = useState("")
|
|
||||||
const [debugError, setDebugError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleOnContinue = useCallback(
|
const handleOnContinue = useCallback(
|
||||||
async (data: XLSX.WorkBook, file: File) => {
|
async (data: XLSX.WorkBook, file: File) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
@@ -46,23 +44,23 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
|
|||||||
},
|
},
|
||||||
[onContinue],
|
[onContinue],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleStartFromScratch = useCallback(() => {
|
const handleStartFromScratch = useCallback(() => {
|
||||||
if (setInitialState) {
|
if (setInitialState) {
|
||||||
setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true })
|
setInitialState({ type: StepType.validateData, data: [{}], isFromScratch: true })
|
||||||
}
|
}
|
||||||
}, [setInitialState])
|
}, [setInitialState])
|
||||||
|
|
||||||
const handleDebugImport = useCallback(() => {
|
const handleJsonImport = useCallback(() => {
|
||||||
setDebugError(null)
|
setJsonError(null)
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(debugJsonInput)
|
const parsed = JSON.parse(jsonInput)
|
||||||
|
|
||||||
// Handle both array and object with products property
|
// Handle both array and object with products property
|
||||||
let products: any[] = Array.isArray(parsed) ? parsed : parsed.products
|
const products: any[] = Array.isArray(parsed) ? parsed : parsed.products
|
||||||
|
|
||||||
if (!Array.isArray(products) || products.length === 0) {
|
if (!Array.isArray(products) || products.length === 0) {
|
||||||
setDebugError("JSON must be an array of products or an object with a 'products' array")
|
setJsonError("JSON must be an array of products or an object with a 'products' array")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,47 +77,59 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
|
|||||||
isFromScratch: true
|
isFromScratch: true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
setDebugDialogOpen(false)
|
|
||||||
setDebugJsonInput("")
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setDebugError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
|
setJsonError(`Invalid JSON: ${e instanceof Error ? e.message : "Parse error"}`)
|
||||||
}
|
}
|
||||||
}, [debugJsonInput, setInitialState])
|
}, [jsonInput, setInitialState])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-8">
|
<div className="p-8">
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
|
<h2 className="text-3xl font-semibold mb-8 text-left">{translations.uploadStep.title}</h2>
|
||||||
{hasDebugPermission && (
|
<div className="max-w-xl mx-auto w-full space-y-8">
|
||||||
|
{hasDebugPermission && (
|
||||||
<>
|
<>
|
||||||
|
<div className="rounded-lg border border-amber-600/40 p-6 space-y-3">
|
||||||
|
<Label htmlFor="import-json" className="flex items-center gap-2 text-amber-600 font-semibold">
|
||||||
<div className="flex justify-center">
|
<Bug className="h-4 w-4" />
|
||||||
<Button
|
|
||||||
onClick={() => setDebugDialogOpen(true)}
|
|
||||||
variant="outline"
|
|
||||||
className="min-w-[200px] text-amber-600 border-amber-600 hover:bg-amber-50"
|
|
||||||
disabled={!setInitialState}
|
|
||||||
>
|
|
||||||
<Bug className="mr-2 h-4 w-4" />
|
|
||||||
Import JSON
|
Import JSON
|
||||||
</Button>
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
|
||||||
|
</p>
|
||||||
|
<Textarea
|
||||||
|
id="import-json"
|
||||||
|
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
|
||||||
|
value={jsonInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setJsonInput(e.target.value)
|
||||||
|
setJsonError(null)
|
||||||
|
}}
|
||||||
|
className="min-h-[160px] font-mono text-sm"
|
||||||
|
/>
|
||||||
|
{jsonError && (
|
||||||
|
<p className="text-sm text-destructive">{jsonError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleJsonImport}
|
||||||
|
disabled={!setInitialState || !jsonInput.trim()}
|
||||||
|
className="bg-amber-600 hover:bg-amber-700"
|
||||||
|
>
|
||||||
|
Import & Go to Validation
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<OrSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
<div className="max-w-xl mx-auto w-full space-y-8">
|
|
||||||
<div className="rounded-lg p-6 flex flex-col items-center">
|
<div className="rounded-lg p-6 flex flex-col items-center">
|
||||||
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
|
<DropZone onContinue={handleOnContinue} isLoading={isLoading} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
<OrSeparator />
|
||||||
<Separator className="w-24" />
|
|
||||||
<span className="px-3 text-muted-foreground text-sm font-medium">OR</span>
|
|
||||||
<Separator className="w-24" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-center pb-8">
|
<div className="flex justify-center pb-8">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleStartFromScratch}
|
onClick={handleStartFromScratch}
|
||||||
@@ -136,50 +146,6 @@ export const UploadStep = ({ onContinue, setInitialState, onRestoreSession }: Up
|
|||||||
<SavedSessionsList onRestore={onRestoreSession} />
|
<SavedSessionsList onRestore={onRestoreSession} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Dialog open={debugDialogOpen} onOpenChange={setDebugDialogOpen}>
|
|
||||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2 text-amber-600">
|
|
||||||
<Bug className="h-5 w-5" />
|
|
||||||
Debug: Import JSON Data
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Paste product data in the same JSON format as the API submission. The data will be loaded into the validation step.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="debug-json">Product JSON</Label>
|
|
||||||
<Textarea
|
|
||||||
id="debug-json"
|
|
||||||
placeholder='[{"supplier": "...", "company": "...", "name": "...", "product_images": "url1,url2", ...}]'
|
|
||||||
value={debugJsonInput}
|
|
||||||
onChange={(e) => {
|
|
||||||
setDebugJsonInput(e.target.value)
|
|
||||||
setDebugError(null)
|
|
||||||
}}
|
|
||||||
className="min-h-[300px] font-mono text-sm"
|
|
||||||
/>
|
|
||||||
{debugError && (
|
|
||||||
<p className="text-sm text-destructive">{debugError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => setDebugDialogOpen(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={handleDebugImport}
|
|
||||||
disabled={!debugJsonInput.trim()}
|
|
||||||
className="bg-amber-600 hover:bg-amber-700"
|
|
||||||
>
|
|
||||||
Import & Go to Validation
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState } from "react"
|
|||||||
import { useRsi } from "../../../hooks/useRsi"
|
import { useRsi } from "../../../hooks/useRsi"
|
||||||
import { readFileAsync } from "../utils/readFilesAsync"
|
import { readFileAsync } from "../utils/readFilesAsync"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { useToast } from "@/hooks/use-toast"
|
import { toast } from "sonner"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
type DropZoneProps = {
|
type DropZoneProps = {
|
||||||
@@ -14,7 +14,6 @@ type DropZoneProps = {
|
|||||||
|
|
||||||
export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
|
export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
|
||||||
const { translations, maxFileSize, dateFormat, parseRaw } = useRsi()
|
const { translations, maxFileSize, dateFormat, parseRaw } = useRsi()
|
||||||
const { toast } = useToast()
|
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({
|
||||||
noClick: true,
|
noClick: true,
|
||||||
@@ -29,9 +28,7 @@ export const DropZone = ({ onContinue, isLoading }: DropZoneProps) => {
|
|||||||
onDropRejected: (fileRejections) => {
|
onDropRejected: (fileRejections) => {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
fileRejections.forEach((fileRejection) => {
|
fileRejections.forEach((fileRejection) => {
|
||||||
toast({
|
toast.error(`${fileRejection.file.name} ${translations.uploadStep.dropzone.errorToastDescription}`, {
|
||||||
variant: "destructive",
|
|
||||||
title: `${fileRejection.file.name} ${translations.uploadStep.dropzone.errorToastDescription}`,
|
|
||||||
description: fileRejection.errors[0].message,
|
description: fileRejection.errors[0].message,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+15
-15
@@ -66,7 +66,7 @@ export function AiSuggestionBadge({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between gap-1.5 px-2 py-1 rounded-md text-xs',
|
'flex items-center justify-between gap-1.5 px-2 py-1 rounded-md text-xs',
|
||||||
'bg-purple-50 border border-purple-200',
|
'bg-purple-50 border border-purple-200',
|
||||||
'dark:bg-purple-950/30 dark:border-purple-800',
|
'',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -77,7 +77,7 @@ export function AiSuggestionBadge({
|
|||||||
type="text"
|
type="text"
|
||||||
value={editedValue}
|
value={editedValue}
|
||||||
onChange={(e) => setEditedValue(e.target.value)}
|
onChange={(e) => setEditedValue(e.target.value)}
|
||||||
className="flex-1 min-w-0 bg-transparent text-purple-700 dark:text-purple-300 text-xs outline-none border-b border-transparent focus:border-purple-300 dark:focus:border-purple-600 transition-colors"
|
className="flex-1 min-w-0 bg-transparent text-purple-700 text-xs outline-none border-b border-transparent focus:border-purple-300 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-[0px] flex-shrink-0">
|
<div className="flex items-center gap-[0px] flex-shrink-0">
|
||||||
@@ -107,7 +107,7 @@ export function AiSuggestionBadge({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100"
|
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-muted-foreground/70 hover:text-foreground hover:bg-muted"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDismiss();
|
onDismiss();
|
||||||
@@ -201,14 +201,14 @@ export function AiSuggestionBadge({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
|
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
|
||||||
'bg-purple-50 border border-purple-200 hover:bg-purple-100',
|
'bg-purple-50 border border-purple-200 hover:bg-purple-100',
|
||||||
'dark:bg-purple-950/30 dark:border-purple-800 dark:hover:bg-purple-900/40',
|
'',
|
||||||
'transition-colors cursor-pointer',
|
'transition-colors cursor-pointer',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
title="Click to see AI suggestion"
|
title="Click to see AI suggestion"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
<span className="text-purple-600 dark:text-purple-400 font-medium">
|
<span className="text-purple-600 font-medium">
|
||||||
{issues.length} {issues.length === 1 ? 'issue' : 'issues'}
|
{issues.length} {issues.length === 1 ? 'issue' : 'issues'}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown className="h-3 w-3 text-purple-400" />
|
<ChevronDown className="h-3 w-3 text-purple-400" />
|
||||||
@@ -222,7 +222,7 @@ export function AiSuggestionBadge({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-col gap-2 p-3 rounded-md',
|
'flex flex-col gap-2 p-3 rounded-md',
|
||||||
'bg-purple-50 border border-purple-200',
|
'bg-purple-50 border border-purple-200',
|
||||||
'dark:bg-purple-950/30 dark:border-purple-800',
|
'',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -230,11 +230,11 @@ export function AiSuggestionBadge({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
|
||||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
<span className="text-xs font-medium text-purple-600">
|
||||||
AI Suggestion
|
AI Suggestion
|
||||||
</span>
|
</span>
|
||||||
{issues.length > 0 && (
|
{issues.length > 0 && (
|
||||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
<span className="text-xs text-purple-500">
|
||||||
({issues.length} {issues.length === 1 ? 'issue' : 'issues'})
|
({issues.length} {issues.length === 1 ? 'issue' : 'issues'})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -261,7 +261,7 @@ export function AiSuggestionBadge({
|
|||||||
{issues.map((issue, index) => (
|
{issues.map((issue, index) => (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className="flex items-start gap-1.5 text-purple-600 dark:text-purple-400"
|
className="flex items-start gap-1.5 text-purple-600"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
|
||||||
<span>{issue}</span>
|
<span>{issue}</span>
|
||||||
@@ -272,10 +272,10 @@ export function AiSuggestionBadge({
|
|||||||
|
|
||||||
{/* Suggested description */}
|
{/* Suggested description */}
|
||||||
<div className="mt-1">
|
<div className="mt-1">
|
||||||
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium">
|
<div className="text-xs text-purple-500 mb-1 font-medium">
|
||||||
Suggested:
|
Suggested:
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed bg-white/50 dark:bg-black/20 rounded p-2 border border-purple-100 dark:border-purple-800">
|
<div className="text-sm text-purple-700 leading-relaxed bg-white/50 rounded p-2 border border-purple-100">
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -285,7 +285,7 @@ export function AiSuggestionBadge({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400"
|
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAccept(suggestion);
|
onAccept(suggestion);
|
||||||
@@ -297,7 +297,7 @@ export function AiSuggestionBadge({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400"
|
className="h-7 px-3 text-xs text-muted-foreground hover:text-foreground"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onDismiss();
|
onDismiss();
|
||||||
@@ -319,12 +319,12 @@ export function AiValidationLoading({ className }: { className?: string }) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 px-2 py-1 rounded-md text-xs',
|
'flex items-center gap-2 px-2 py-1 rounded-md text-xs',
|
||||||
'bg-purple-50 border border-purple-200',
|
'bg-purple-50 border border-purple-200',
|
||||||
'dark:bg-purple-950/30 dark:border-purple-800',
|
'',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="h-3 w-3 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
<div className="h-3 w-3 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||||
<span className="text-purple-600 dark:text-purple-400">
|
<span className="text-purple-600">
|
||||||
Validating with AI...
|
Validating with AI...
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+5
-5
@@ -114,21 +114,21 @@ export const CopyDownBanner = memo(() => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div ref={bannerRef} className="pointer-events-auto">
|
<div ref={bannerRef} className="pointer-events-auto">
|
||||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
<div className="bg-blue-50 border border-blue-200 rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
|
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||||
<span className="text-xs font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap">
|
<span className="text-xs font-medium text-blue-700 whitespace-nowrap">
|
||||||
Click row to copy to
|
Click row to copy to
|
||||||
</span>
|
</span>
|
||||||
{rowsBelow > 0 && (
|
{rowsBelow > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="h-4 w-px bg-blue-200 dark:bg-blue-800" />
|
<div className="h-4 w-px bg-blue-200" />
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleApplyToAll}
|
onClick={handleApplyToAll}
|
||||||
className="h-6 px-2 text-xs font-medium text-blue-700 hover:text-blue-900 hover:bg-blue-100 dark:text-blue-300 dark:hover:bg-blue-900 dark:hover:text-blue-100"
|
className="h-6 px-2 text-xs font-medium text-blue-700 hover:text-blue-900 hover:bg-blue-100"
|
||||||
>
|
>
|
||||||
<ArrowDownToLine className="h-3 w-3 mr-1" />
|
<ArrowDownToLine className="h-3 w-3 mr-1" />
|
||||||
All
|
All
|
||||||
@@ -149,7 +149,7 @@ export const CopyDownBanner = memo(() => {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900"
|
className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100"
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
+9
-9
@@ -62,7 +62,7 @@ export function SuggestionBadges({
|
|||||||
<div className={cn('flex flex-wrap items-center gap-1.5', className)}>
|
<div className={cn('flex flex-wrap items-center gap-1.5', className)}>
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'flex items-center gap-1 text-purple-600 dark:text-purple-400',
|
'flex items-center gap-1 text-purple-600',
|
||||||
compact ? 'text-[10px]' : 'text-xs'
|
compact ? 'text-[10px]' : 'text-xs'
|
||||||
)}>
|
)}>
|
||||||
<Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} />
|
<Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} />
|
||||||
@@ -71,7 +71,7 @@ export function SuggestionBadges({
|
|||||||
|
|
||||||
{/* Loading state */}
|
{/* Loading state */}
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="flex items-center gap-1 text-gray-400">
|
<div className="flex items-center gap-1 text-muted-foreground/70">
|
||||||
<Loader2 className={cn('animate-spin', compact ? 'h-2.5 w-2.5' : 'h-3 w-3')} />
|
<Loader2 className={cn('animate-spin', compact ? 'h-2.5 w-2.5' : 'h-3 w-3')} />
|
||||||
{!compact && <span className="text-xs">Loading...</span>}
|
{!compact && <span className="text-xs">Loading...</span>}
|
||||||
</div>
|
</div>
|
||||||
@@ -92,8 +92,8 @@ export function SuggestionBadges({
|
|||||||
'inline-flex items-center gap-1 rounded-full border transition-colors',
|
'inline-flex items-center gap-1 rounded-full border transition-colors',
|
||||||
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
|
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
|
||||||
selected
|
selected
|
||||||
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-950 dark:text-green-400'
|
? 'border-green-300 bg-green-50 text-green-700'
|
||||||
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100 dark:border-purple-800 dark:bg-purple-950/50 dark:text-purple-300 dark:hover:bg-purple-900/50'
|
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
|
||||||
)}
|
)}
|
||||||
title={suggestion.fullPath || suggestion.name}
|
title={suggestion.fullPath || suggestion.name}
|
||||||
>
|
>
|
||||||
@@ -141,8 +141,8 @@ export function InlineSuggestion({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between px-2 py-1.5 cursor-pointer',
|
'flex items-center justify-between px-2 py-1.5 cursor-pointer',
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-green-50 dark:bg-green-950/30'
|
? 'bg-green-50'
|
||||||
: 'bg-purple-50/50 hover:bg-purple-100/50 dark:bg-purple-950/20 dark:hover:bg-purple-900/30'
|
: 'bg-purple-50/50 hover:bg-purple-100/50'
|
||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
@@ -154,7 +154,7 @@ export function InlineSuggestion({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||||
{showScore && (
|
{showScore && (
|
||||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
<span className="text-xs text-purple-500">
|
||||||
{similarityPercent}%
|
{similarityPercent}%
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -178,12 +178,12 @@ interface SuggestionSectionHeaderProps {
|
|||||||
|
|
||||||
export function SuggestionSectionHeader({ isLoading, count }: SuggestionSectionHeaderProps) {
|
export function SuggestionSectionHeader({ isLoading, count }: SuggestionSectionHeaderProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 bg-purple-50/80 border-b border-purple-100">
|
||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles className="h-3 w-3" />
|
||||||
<span>AI Suggested</span>
|
<span>AI Suggested</span>
|
||||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
|
{isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
{!isLoading && count !== undefined && (
|
{!isLoading && count !== undefined && (
|
||||||
<span className="text-purple-400 dark:text-purple-500">({count})</span>
|
<span className="text-purple-400">({count})</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+5
-5
@@ -871,8 +871,8 @@ const CellWrapper = memo(({
|
|||||||
const cellHighlightClass = cn(
|
const cellHighlightClass = cn(
|
||||||
'relative w-full group',
|
'relative w-full group',
|
||||||
isCopyDownSource && 'ring-2 ring-blue-500 ring-inset rounded',
|
isCopyDownSource && 'ring-2 ring-blue-500 ring-inset rounded',
|
||||||
isInCopyDownRange && 'bg-blue-100 dark:bg-blue-900/30',
|
isInCopyDownRange && 'bg-blue-100',
|
||||||
isCopyDownTarget && !isInCopyDownRange && 'hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer'
|
isCopyDownTarget && !isInCopyDownRange && 'hover:bg-blue-50 cursor-pointer'
|
||||||
);
|
);
|
||||||
|
|
||||||
// When in copy-down mode for this field, make cell non-interactive so clicks go to parent
|
// When in copy-down mode for this field, make cell non-interactive so clicks go to parent
|
||||||
@@ -933,7 +933,7 @@ const CellWrapper = memo(({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full',
|
'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full',
|
||||||
'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
|
'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
|
||||||
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400',
|
'',
|
||||||
'shadow-sm',
|
'shadow-sm',
|
||||||
// Position further left if there are errors to avoid overlap
|
// Position further left if there are errors to avoid overlap
|
||||||
hasErrors ? 'right-7' : 'right-0.5'
|
hasErrors ? 'right-7' : 'right-0.5'
|
||||||
@@ -1420,7 +1420,7 @@ const VirtualRow = memo(({
|
|||||||
// Use box-shadow for bottom border - renders more consistently with transforms than border-b
|
// Use box-shadow for bottom border - renders more consistently with transforms than border-b
|
||||||
'shadow-[inset_0_-1px_0_0_hsl(var(--border))]',
|
'shadow-[inset_0_-1px_0_0_hsl(var(--border))]',
|
||||||
hasErrors && 'bg-destructive/5',
|
hasErrors && 'bg-destructive/5',
|
||||||
isSelected && 'bg-blue-100 dark:bg-blue-900/40'
|
isSelected && 'bg-blue-100'
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
height: ROW_HEIGHT,
|
height: ROW_HEIGHT,
|
||||||
@@ -1534,7 +1534,7 @@ const VirtualRow = memo(({
|
|||||||
// Selection (blue) takes priority over errors (red)
|
// Selection (blue) takes priority over errors (red)
|
||||||
shouldBeSticky && (
|
shouldBeSticky && (
|
||||||
isSelected
|
isSelected
|
||||||
? "lg:[background:#dbeafe] lg:dark:[background:hsl(221_83%_25%/0.4)]"
|
? "lg:[background:#dbeafe] lg:(221_83%_25%/0.4)]"
|
||||||
: hasErrors
|
: hasErrors
|
||||||
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
|
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
|
||||||
: "lg:[background:hsl(var(--background))]"
|
: "lg:[background:hsl(var(--background))]"
|
||||||
|
|||||||
+5
-5
@@ -271,7 +271,7 @@ const MultiSelectCellComponent = ({
|
|||||||
{/* Selected items section - floats to top of dropdown */}
|
{/* Selected items section - floats to top of dropdown */}
|
||||||
{selectedValues.length > 0 && (
|
{selectedValues.length > 0 && (
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50/80 dark:bg-green-950/40 border-b border-green-100 dark:border-green-900">
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 bg-green-50/80 border-b border-green-100">
|
||||||
<Check className="h-3 w-3" />
|
<Check className="h-3 w-3" />
|
||||||
<span>Selected ({selectedValues.length})</span>
|
<span>Selected ({selectedValues.length})</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -286,7 +286,7 @@ const MultiSelectCellComponent = ({
|
|||||||
key={`selected-${selectedVal}`}
|
key={`selected-${selectedVal}`}
|
||||||
value={`selected-${label}`}
|
value={`selected-${label}`}
|
||||||
onSelect={() => handleSelect(selectedVal)}
|
onSelect={() => handleSelect(selectedVal)}
|
||||||
className="bg-green-50/50 dark:bg-green-950/30"
|
className="bg-green-50/50"
|
||||||
>
|
>
|
||||||
<Check className="mr-2 h-4 w-4 opacity-100 text-green-600" />
|
<Check className="mr-2 h-4 w-4 opacity-100 text-green-600" />
|
||||||
{field.key === 'colors' && hexColor && (
|
{field.key === 'colors' && hexColor && (
|
||||||
@@ -308,7 +308,7 @@ const MultiSelectCellComponent = ({
|
|||||||
{/* AI Suggestions section - shown below selected items */}
|
{/* AI Suggestions section - shown below selected items */}
|
||||||
{supportsSuggestions && (fieldSuggestions.length > 0 || suggestions.isLoading) && (
|
{supportsSuggestions && (fieldSuggestions.length > 0 || suggestions.isLoading) && (
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900">
|
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 bg-purple-50/80 border-b border-purple-100">
|
||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles className="h-3 w-3" />
|
||||||
<span>Suggested</span>
|
<span>Suggested</span>
|
||||||
{suggestions.isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
|
{suggestions.isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||||
@@ -328,7 +328,7 @@ const MultiSelectCellComponent = ({
|
|||||||
key={`suggestion-${suggestion.id}`}
|
key={`suggestion-${suggestion.id}`}
|
||||||
value={`suggestion-${suggestion.name}`}
|
value={`suggestion-${suggestion.name}`}
|
||||||
onSelect={() => handleSelect(String(suggestion.id))}
|
onSelect={() => handleSelect(String(suggestion.id))}
|
||||||
className="bg-purple-50/30 dark:bg-purple-950/20"
|
className="bg-purple-50/30"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||||
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
<Check className="h-4 w-4 flex-shrink-0 opacity-0" />
|
||||||
@@ -347,7 +347,7 @@ const MultiSelectCellComponent = ({
|
|||||||
{field.key === 'colors' ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
{field.key === 'colors' ? suggestion.name : (suggestion.fullPath || suggestion.name)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0">
|
<span className="text-xs text-purple-500 ml-2 flex-shrink-0">
|
||||||
{similarityPercent}%
|
{similarityPercent}%
|
||||||
</span>
|
</span>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
|
|||||||
+3
-3
@@ -313,7 +313,7 @@ const MultilineInputComponent = ({
|
|||||||
'overflow-hidden leading-tight h-[65px] top-0',
|
'overflow-hidden leading-tight h-[65px] top-0',
|
||||||
'border',
|
'border',
|
||||||
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
|
hasError ? 'border-destructive bg-destructive/5' : 'border-input',
|
||||||
hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20',
|
hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50',
|
||||||
isValidating && 'opacity-50'
|
isValidating && 'opacity-50'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -335,7 +335,7 @@ const MultilineInputComponent = ({
|
|||||||
setEditValue(initValue);
|
setEditValue(initValue);
|
||||||
initialEditValueRef.current = initValue;
|
initialEditValueRef.current = initValue;
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors"
|
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 text-purple-600 text-xs transition-colors"
|
||||||
title="View AI suggestion"
|
title="View AI suggestion"
|
||||||
>
|
>
|
||||||
<Sparkles className="h-3 w-3" />
|
<Sparkles className="h-3 w-3" />
|
||||||
@@ -344,7 +344,7 @@ const MultilineInputComponent = ({
|
|||||||
)}
|
)}
|
||||||
{/* AI validating indicator */}
|
{/* AI validating indicator */}
|
||||||
{isAiValidating && (
|
{isAiValidating && (
|
||||||
<div className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/50">
|
<div className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100">
|
||||||
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
|
<Loader2 className="h-3 w-3 animate-spin text-purple-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+8
-8
@@ -67,7 +67,7 @@ const DiffDisplay = ({ original, corrected }: { original: unknown; corrected: un
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 rounded px-0.5"
|
className="bg-green-100 text-green-800 rounded px-0.5"
|
||||||
>
|
>
|
||||||
{part.value}
|
{part.value}
|
||||||
</span>
|
</span>
|
||||||
@@ -77,7 +77,7 @@ const DiffDisplay = ({ original, corrected }: { original: unknown; corrected: un
|
|||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={index}
|
key={index}
|
||||||
className="bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 line-through rounded px-0.5"
|
className="bg-red-100 text-red-800 line-through rounded px-0.5"
|
||||||
>
|
>
|
||||||
{part.value}
|
{part.value}
|
||||||
</span>
|
</span>
|
||||||
@@ -153,22 +153,22 @@ const SummaryDisplay = ({
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
{summary && (
|
{summary && (
|
||||||
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800">
|
<div className="p-3 rounded-lg bg-blue-50 border border-blue-200">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
<Info className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||||
<div className="text-sm text-blue-800 dark:text-blue-200">{summary}</div>
|
<div className="text-sm text-blue-800">{summary}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Warnings */}
|
{/* Warnings */}
|
||||||
{warnings && warnings.length > 0 && (
|
{warnings && warnings.length > 0 && (
|
||||||
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800">
|
<div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" />
|
<AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{warnings.map((warning, index) => (
|
{warnings.map((warning, index) => (
|
||||||
<div key={index} className="text-sm text-amber-800 dark:text-amber-200">
|
<div key={index} className="text-sm text-amber-800">
|
||||||
{warning}
|
{warning}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+3
-3
@@ -205,7 +205,7 @@ export function SanityCheckDialog({
|
|||||||
{Object.entries(groups).map(([key, group]) => (
|
{Object.entries(groups).map(([key, group]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 border border-gray-200 hover:border-gray-300 transition-colors"
|
className="flex items-start gap-3 p-3 rounded-lg bg-surface-subtle border border-border hover:border-muted-foreground/30 transition-colors"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -234,12 +234,12 @@ export function SanityCheckDialog({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{idx < group.productIndices.length - 1 && (
|
{idx < group.productIndices.length - 1 && (
|
||||||
<span className="text-gray-400 mr-1">,</span>
|
<span className="text-muted-foreground/70 mr-1">,</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600">{group.issue}</p>
|
<p className="text-sm text-muted-foreground">{group.issue}</p>
|
||||||
{group.suggestion && (
|
{group.suggestion && (
|
||||||
<p className="text-xs text-blue-600 mt-1">
|
<p className="text-xs text-blue-600 mt-1">
|
||||||
{group.suggestion}
|
{group.suggestion}
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
|||||||
<VaulDrawer.Overlay className="fixed inset-0 bg-black/40 z-40" />
|
<VaulDrawer.Overlay className="fixed inset-0 bg-black/40 z-40" />
|
||||||
<VaulDrawer.Content className="fixed right-0 top-0 h-dvh w-[90%] max-w-[800px] bg-background p-0 shadow-lg flex flex-col z-50">
|
<VaulDrawer.Content className="fixed right-0 top-0 h-dvh w-[90%] max-w-[800px] bg-background p-0 shadow-lg flex flex-col z-50">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-start justify-between p-4 border-b sticky top-0 bg-background z-10">
|
<div className="flex items-start justify-between p-4 border-b sticky top-0 bg-card z-10">
|
||||||
<div className="flex items-center gap-4 overflow-hidden">
|
<div className="flex items-center gap-4 overflow-hidden">
|
||||||
{isLoadingProduct ? (
|
{isLoadingProduct ? (
|
||||||
<Skeleton className="h-16 w-16 rounded-lg" />
|
<Skeleton className="h-16 w-16 rounded-lg" />
|
||||||
|
|||||||
@@ -198,15 +198,15 @@ export function ProductTable({
|
|||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="border rounded-md relative">
|
<div className="border rounded-xl bg-card relative overflow-hidden">
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20">
|
<div className="absolute inset-0 bg-card/70 flex items-center justify-center z-20">
|
||||||
<Skeleton className="h-8 w-32" />
|
<Skeleton className="h-8 w-32" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className={cn(isLoading ? 'opacity-50' : '', "w-max min-w-full")}>
|
<Table className={cn(isLoading ? 'opacity-50' : '', "w-max min-w-full")}>
|
||||||
<TableHeader className="sticky top-0 bg-background z-10">
|
<TableHeader className="sticky top-0 bg-card z-10">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={orderedVisibleColumns}
|
items={orderedVisibleColumns}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ export default function FilterControls({
|
|||||||
placeholder="Search orders..."
|
placeholder="Search orders..."
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.target.value)}
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
className="max-w-xs"
|
className="max-w-xs rounded-full px-3.5"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
|||||||
@@ -106,11 +106,11 @@ export default function PipelineCard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{data.overdue.count > 0 ? (
|
{data.overdue.count > 0 ? (
|
||||||
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50/50 dark:border-red-900/50 dark:bg-red-950/20 p-2.5">
|
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50/50 p-2.5">
|
||||||
<AlertTriangle className="h-4 w-4 text-red-500" />
|
<AlertTriangle className="h-4 w-4 text-red-500" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-red-600 dark:text-red-400">Overdue</p>
|
<p className="text-xs text-red-600">Overdue</p>
|
||||||
<p className="text-sm font-bold text-red-600 dark:text-red-400">
|
<p className="text-sm font-bold text-red-600">
|
||||||
{data.overdue.count} POs ({formatCurrency(data.overdue.value)})
|
{data.overdue.count} POs ({formatCurrency(data.overdue.value)})
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function PurchaseOrderAccordion({
|
|||||||
// Clone the TableRow (children) and add the onClick handler and className
|
// Clone the TableRow (children) and add the onClick handler and className
|
||||||
const enhancedRow = React.cloneElement(children as React.ReactElement, {
|
const enhancedRow = React.cloneElement(children as React.ReactElement, {
|
||||||
onClick: () => setIsOpen(!isOpen),
|
onClick: () => setIsOpen(!isOpen),
|
||||||
className: `${(children as React.ReactElement).props.className || ""} cursor-pointer ${isOpen ? 'bg-gray-100' : ''} ${rowClassName || ""}`.trim(),
|
className: `${(children as React.ReactElement).props.className || ""} cursor-pointer ${isOpen ? "bg-muted" : ""} ${rowClassName || ""}`.trim(),
|
||||||
"data-state": isOpen ? "open" : "closed"
|
"data-state": isOpen ? "open" : "closed"
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ export default function PurchaseOrderAccordion({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-h-[600px] overflow-y-auto bg-gray-50 rounded-md p-2">
|
<div className="max-h-[600px] overflow-y-auto bg-surface-subtle rounded-md p-2">
|
||||||
<Table className="w-full">
|
<Table className="w-full">
|
||||||
<TableHeader className="bg-white sticky top-0 z-10">
|
<TableHeader className="bg-white sticky top-0 z-10">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -151,7 +151,7 @@ export default function PurchaseOrderAccordion({
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<TableRow key={item.id} className="hover:bg-gray-100">
|
<TableRow key={item.id} className="hover:bg-muted">
|
||||||
<TableCell className="">
|
<TableCell className="">
|
||||||
<a
|
<a
|
||||||
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
||||||
@@ -228,7 +228,7 @@ export default function PurchaseOrderAccordion({
|
|||||||
{isOpen && (
|
{isOpen && (
|
||||||
<TableRow className="p-0 border-0">
|
<TableRow className="p-0 border-0">
|
||||||
<TableCell colSpan={12} className="p-0 border-0">
|
<TableCell colSpan={12} className="p-0 border-0">
|
||||||
<div className="pt-2 pb-4 px-4 bg-gray-50 border-t border-b">
|
<div className="pt-2 pb-4 px-4 bg-surface-subtle border-t border-b">
|
||||||
<div className="mb-2 text-sm text-muted-foreground">
|
<div className="mb-2 text-sm text-muted-foreground">
|
||||||
{purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"}
|
{purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function PurchaseOrdersTable({
|
|||||||
return (
|
return (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="flex items-center justify-center border-gray-500 text-gray-700 bg-gray-50 px-0 text-xs w-[85px]"
|
className="flex items-center justify-center border-muted-foreground/40 text-muted-foreground bg-surface-subtle px-0 text-xs w-[85px]"
|
||||||
>
|
>
|
||||||
{recordType || "Unknown"}
|
{recordType || "Unknown"}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -323,7 +323,7 @@ export default function PurchaseOrdersTable({
|
|||||||
) : purchaseOrders.length > 0 ? (
|
) : purchaseOrders.length > 0 ? (
|
||||||
purchaseOrders.map((po) => {
|
purchaseOrders.map((po) => {
|
||||||
// Determine row styling based on record type
|
// Determine row styling based on record type
|
||||||
let rowClassName = "border-l-4 border-l-gray-300"; // Default
|
let rowClassName = "border-l-4 border-l-[#d8d4cd]"; // Default
|
||||||
|
|
||||||
if (po.record_type === "po_with_receiving") {
|
if (po.record_type === "po_with_receiving") {
|
||||||
rowClassName = "border-l-4 border-l-green-500";
|
rowClassName = "border-l-4 border-l-green-500";
|
||||||
|
|||||||
@@ -307,11 +307,11 @@ function Filters({
|
|||||||
|
|
||||||
function SuccessBadge({ success }: { success: boolean }) {
|
function SuccessBadge({ success }: { success: boolean }) {
|
||||||
return success ? (
|
return success ? (
|
||||||
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50 dark:bg-green-950 dark:border-green-800 dark:text-green-400">
|
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50">
|
||||||
Success
|
Success
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50 dark:bg-red-950 dark:border-red-800 dark:text-red-400">
|
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50">
|
||||||
Failed
|
Failed
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
@@ -342,7 +342,7 @@ function EditorTable({ entries, onView }: { entries: AuditEntry[]; onView: (id:
|
|||||||
href={`https://backend.acherryontop.com/product/${e.pid}`}
|
href={`https://backend.acherryontop.com/product/${e.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-mono text-sm hover:underline text-blue-600 dark:text-blue-400"
|
className="font-mono text-sm hover:underline text-blue-600"
|
||||||
>
|
>
|
||||||
{e.pid}
|
{e.pid}
|
||||||
</a>
|
</a>
|
||||||
@@ -434,7 +434,7 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
|
|||||||
href={`https://backend.acherryontop.com/product/${detail.pid}`}
|
href={`https://backend.acherryontop.com/product/${detail.pid}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="font-mono hover:underline text-blue-600 dark:text-blue-400"
|
className="font-mono hover:underline text-blue-600"
|
||||||
>
|
>
|
||||||
{detail.pid}
|
{detail.pid}
|
||||||
</a>
|
</a>
|
||||||
@@ -478,7 +478,7 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
|
|||||||
{detail.error_message && (
|
{detail.error_message && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-muted-foreground">Error</span>
|
<span className="text-muted-foreground">Error</span>
|
||||||
<p className="text-red-600 dark:text-red-400 mt-1 text-xs bg-red-50 dark:bg-red-950 p-2 rounded-md break-all">
|
<p className="text-red-600 mt-1 text-xs bg-red-50 p-2 rounded-md break-all">
|
||||||
{detail.error_message}
|
{detail.error_message}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -576,11 +576,11 @@ function JsonValue({ value, depth }: { value: unknown; depth: number }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "boolean") {
|
if (typeof value === "boolean") {
|
||||||
return <span className={value ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>{String(value)}</span>;
|
return <span className={value ? "text-green-600" : "text-red-600"}>{String(value)}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
return <span className="text-blue-600 dark:text-blue-400">{value}</span>;
|
return <span className="text-blue-600">{value}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
@@ -605,7 +605,7 @@ function JsonString({ value }: { value: string }) {
|
|||||||
|
|
||||||
if (value.length <= STRING_TRUNCATE_LIMIT || expanded) {
|
if (value.length <= STRING_TRUNCATE_LIMIT || expanded) {
|
||||||
return (
|
return (
|
||||||
<span className="text-amber-700 dark:text-amber-400 break-words whitespace-pre-wrap">
|
<span className="text-amber-700 break-words whitespace-pre-wrap">
|
||||||
{value}
|
{value}
|
||||||
{expanded && value.length > STRING_TRUNCATE_LIMIT && (
|
{expanded && value.length > STRING_TRUNCATE_LIMIT && (
|
||||||
<button onClick={() => setExpanded(false)} className="ml-1 text-muted-foreground hover:text-foreground text-[10px] underline">
|
<button onClick={() => setExpanded(false)} className="ml-1 text-muted-foreground hover:text-foreground text-[10px] underline">
|
||||||
@@ -617,7 +617,7 @@ function JsonString({ value }: { value: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="text-amber-700 dark:text-amber-400 break-words whitespace-pre-wrap">
|
<span className="text-amber-700 break-words whitespace-pre-wrap">
|
||||||
{value.slice(0, STRING_TRUNCATE_LIMIT)}
|
{value.slice(0, STRING_TRUNCATE_LIMIT)}
|
||||||
<span className="text-muted-foreground">... </span>
|
<span className="text-muted-foreground">... </span>
|
||||||
<button onClick={() => setExpanded(true)} className="text-muted-foreground hover:text-foreground text-[10px] underline">
|
<button onClick={() => setExpanded(true)} className="text-muted-foreground hover:text-foreground text-[10px] underline">
|
||||||
@@ -642,7 +642,7 @@ function JsonObject({ obj, depth }: { obj: Record<string, unknown>; depth: numbe
|
|||||||
{"{ "}
|
{"{ "}
|
||||||
{entries.map(([k, v], i) => (
|
{entries.map(([k, v], i) => (
|
||||||
<span key={k}>
|
<span key={k}>
|
||||||
<span className="text-purple-600 dark:text-purple-400">{k}</span>
|
<span className="text-purple-600">{k}</span>
|
||||||
<span className="text-muted-foreground">: </span>
|
<span className="text-muted-foreground">: </span>
|
||||||
<JsonValue value={v} depth={depth + 1} />
|
<JsonValue value={v} depth={depth + 1} />
|
||||||
{i < entries.length - 1 && <span className="text-muted-foreground">, </span>}
|
{i < entries.length - 1 && <span className="text-muted-foreground">, </span>}
|
||||||
@@ -676,7 +676,7 @@ function JsonField({ fieldKey, value, depth }: { fieldKey: string; value: unknow
|
|||||||
className="inline-flex items-center gap-1 hover:text-foreground"
|
className="inline-flex items-center gap-1 hover:text-foreground"
|
||||||
>
|
>
|
||||||
{open ? <ChevronDown className="h-3 w-3 text-muted-foreground" /> : <ChevronRightIcon className="h-3 w-3 text-muted-foreground" />}
|
{open ? <ChevronDown className="h-3 w-3 text-muted-foreground" /> : <ChevronRightIcon className="h-3 w-3 text-muted-foreground" />}
|
||||||
<span className="text-purple-600 dark:text-purple-400 font-medium">{fieldKey}</span>
|
<span className="text-purple-600 font-medium">{fieldKey}</span>
|
||||||
</button>
|
</button>
|
||||||
{open && (
|
{open && (
|
||||||
<div className="ml-4 border-l border-border pl-3 mt-1">
|
<div className="ml-4 border-l border-border pl-3 mt-1">
|
||||||
@@ -690,7 +690,7 @@ function JsonField({ fieldKey, value, depth }: { fieldKey: string; value: unknow
|
|||||||
// Primitive or small array — render inline
|
// Primitive or small array — render inline
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-purple-600 dark:text-purple-400 font-medium">{fieldKey}</span>
|
<span className="text-purple-600 font-medium">{fieldKey}</span>
|
||||||
<span className="text-muted-foreground">: </span>
|
<span className="text-muted-foreground">: </span>
|
||||||
<JsonValue value={value} depth={depth + 1} />
|
<JsonValue value={value} depth={depth + 1} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -401,12 +401,12 @@ export function DataManagement() {
|
|||||||
if (data.completed_steps && Array.isArray(data.completed_steps)) {
|
if (data.completed_steps && Array.isArray(data.completed_steps)) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 mt-2">
|
<div className="space-y-2 mt-2">
|
||||||
<div className="text-sm font-semibold text-gray-700">Completed Steps:</div>
|
<div className="text-sm font-semibold text-foreground">Completed Steps:</div>
|
||||||
<div className="space-y-1 bg-gray-50 p-2 rounded">
|
<div className="space-y-1 bg-surface-subtle p-2 rounded">
|
||||||
{data.completed_steps.map((step: any, idx: number) => (
|
{data.completed_steps.map((step: any, idx: number) => (
|
||||||
<div key={idx} className="flex justify-between text-sm">
|
<div key={idx} className="flex justify-between text-sm">
|
||||||
<span className="font-medium">{step.name}</span>
|
<span className="font-medium">{step.name}</span>
|
||||||
<div className="flex gap-4 text-gray-600">
|
<div className="flex gap-4 text-muted-foreground">
|
||||||
<span>{step.duration}s</span>
|
<span>{step.duration}s</span>
|
||||||
{step.rowsAffected !== undefined && (
|
{step.rowsAffected !== undefined && (
|
||||||
<span>{formatNumber(step.rowsAffected)} rows</span>
|
<span>{formatNumber(step.rowsAffected)} rows</span>
|
||||||
@@ -434,15 +434,15 @@ export function DataManagement() {
|
|||||||
if (data.details) {
|
if (data.details) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 mt-2">
|
<div className="space-y-2 mt-2">
|
||||||
<div className="text-sm font-semibold text-gray-700">Import Details:</div>
|
<div className="text-sm font-semibold text-foreground">Import Details:</div>
|
||||||
<div className="space-y-2 bg-gray-50 p-2 rounded">
|
<div className="space-y-2 bg-surface-subtle p-2 rounded">
|
||||||
{Object.entries(data.details).map(([table, stats]: [string, any]) => (
|
{Object.entries(data.details).map(([table, stats]: [string, any]) => (
|
||||||
<div key={table} className="border-b last:border-0 pb-2 last:pb-0">
|
<div key={table} className="border-b last:border-0 pb-2 last:pb-0">
|
||||||
<div className="font-medium text-sm capitalize mb-1">{table}:</div>
|
<div className="font-medium text-sm capitalize mb-1">{table}:</div>
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||||
{Object.entries(stats).map(([key, value]) => (
|
{Object.entries(stats).map(([key, value]) => (
|
||||||
<div key={key} className="flex justify-between">
|
<div key={key} className="flex justify-between">
|
||||||
<span className="text-gray-600">{key}:</span>
|
<span className="text-muted-foreground">{key}:</span>
|
||||||
<span className="font-mono">{typeof value === 'number' ? formatNumber(value) : String(value)}</span>
|
<span className="font-mono">{typeof value === 'number' ? formatNumber(value) : String(value)}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -453,11 +453,11 @@ export function DataManagement() {
|
|||||||
{/* Show step timings if present */}
|
{/* Show step timings if present */}
|
||||||
{data.step_timings && (
|
{data.step_timings && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="text-sm font-semibold text-gray-700">Step Timings:</div>
|
<div className="text-sm font-semibold text-foreground">Step Timings:</div>
|
||||||
<div className="space-y-1 bg-gray-50 p-2 rounded text-sm">
|
<div className="space-y-1 bg-surface-subtle p-2 rounded text-sm">
|
||||||
{Object.entries(data.step_timings).map(([step, duration]) => (
|
{Object.entries(data.step_timings).map(([step, duration]) => (
|
||||||
<div key={step} className="flex justify-between">
|
<div key={step} className="flex justify-between">
|
||||||
<span className="text-gray-600">{step}:</span>
|
<span className="text-muted-foreground">{step}:</span>
|
||||||
<span className="font-mono">{String(duration)}s</span>
|
<span className="font-mono">{String(duration)}s</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -480,11 +480,11 @@ export function DataManagement() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1 mt-2 bg-gray-50 p-2 rounded text-sm font-mono">
|
<div className="space-y-1 mt-2 bg-surface-subtle p-2 rounded text-sm font-mono">
|
||||||
{Object.entries(data).map(([key, value]) => (
|
{Object.entries(data).map(([key, value]) => (
|
||||||
<div key={key} className="flex">
|
<div key={key} className="flex">
|
||||||
<span
|
<span
|
||||||
className="text-gray-600 font-bold shrink-0"
|
className="text-muted-foreground font-bold shrink-0"
|
||||||
style={{ width: `${maxKeyLength + 2}ch` }}
|
style={{ width: `${maxKeyLength + 2}ch` }}
|
||||||
>
|
>
|
||||||
{key}:
|
{key}:
|
||||||
@@ -920,7 +920,7 @@ export function DataManagement() {
|
|||||||
{table.error ? (
|
{table.error ? (
|
||||||
<span className="text-red-600 text-sm">{table.error}</span>
|
<span className="text-red-600 text-sm">{table.error}</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-sm text-gray-600">{formatNumber(table.count)}</span>
|
<span className="text-sm text-muted-foreground">{formatNumber(table.count)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -1104,7 +1104,7 @@ export function DataManagement() {
|
|||||||
className="flex justify-between text-sm items-center py-2 border-b last:border-0"
|
className="flex justify-between text-sm items-center py-2 border-b last:border-0"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{table.table_name}</span>
|
<span className="font-medium">{table.table_name}</span>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-muted-foreground">
|
||||||
{formatStatusTime(table.last_sync_timestamp)}
|
{formatStatusTime(table.last_sync_timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1146,7 +1146,7 @@ export function DataManagement() {
|
|||||||
className="flex justify-between text-sm items-center py-2 border-b last:border-0"
|
className="flex justify-between text-sm items-center py-2 border-b last:border-0"
|
||||||
>
|
>
|
||||||
<span className="font-medium">{module.module_name}</span>
|
<span className="font-medium">{module.module_name}</span>
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-muted-foreground">
|
||||||
{formatStatusTime(module.last_calculation_timestamp)}
|
{formatStatusTime(module.last_calculation_timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1197,7 +1197,7 @@ export function DataManagement() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[170px]">
|
<div className="w-[170px]">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-muted-foreground">
|
||||||
{formatDate(record.start_time)}
|
{formatDate(record.start_time)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1230,7 +1230,7 @@ export function DataManagement() {
|
|||||||
<AccordionContent className="px-4 pb-2">
|
<AccordionContent className="px-4 pb-2">
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">End Time:</span>
|
<span className="text-muted-foreground">End Time:</span>
|
||||||
<span>
|
<span>
|
||||||
{record.end_time
|
{record.end_time
|
||||||
? formatDate(record.end_time)
|
? formatDate(record.end_time)
|
||||||
@@ -1239,28 +1239,28 @@ export function DataManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Added:</span>
|
<span className="text-muted-foreground">Added:</span>
|
||||||
<span className="text-green-600 font-medium">{formatNumber(record.records_added)}</span>
|
<span className="text-green-600 font-medium">{formatNumber(record.records_added)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Updated:</span>
|
<span className="text-muted-foreground">Updated:</span>
|
||||||
<span className="text-blue-600 font-medium">{formatNumber(record.records_updated)}</span>
|
<span className="text-blue-600 font-medium">{formatNumber(record.records_updated)}</span>
|
||||||
</div>
|
</div>
|
||||||
{record.records_deleted !== undefined && (
|
{record.records_deleted !== undefined && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Deleted:</span>
|
<span className="text-muted-foreground">Deleted:</span>
|
||||||
<span className="text-red-600 font-medium">{formatNumber(record.records_deleted)}</span>
|
<span className="text-red-600 font-medium">{formatNumber(record.records_deleted)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{record.records_skipped !== undefined && (
|
{record.records_skipped !== undefined && (
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600">Skipped:</span>
|
<span className="text-muted-foreground">Skipped:</span>
|
||||||
<span className="text-yellow-600 font-medium">{formatNumber(record.records_skipped)}</span>
|
<span className="text-yellow-600 font-medium">{formatNumber(record.records_skipped)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{record.total_processed !== undefined && (
|
{record.total_processed !== undefined && (
|
||||||
<div className="flex justify-between col-span-2">
|
<div className="flex justify-between col-span-2">
|
||||||
<span className="text-gray-600">Total Processed:</span>
|
<span className="text-muted-foreground">Total Processed:</span>
|
||||||
<span className="font-medium">{formatNumber(record.total_processed)}</span>
|
<span className="font-medium">{formatNumber(record.total_processed)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1319,7 +1319,7 @@ export function DataManagement() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-[170px]">
|
<div className="w-[170px]">
|
||||||
<span className="text-sm text-gray-600">
|
<span className="text-sm text-muted-foreground">
|
||||||
{formatDate(record.start_time)}
|
{formatDate(record.start_time)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1352,7 +1352,7 @@ export function DataManagement() {
|
|||||||
<AccordionContent className="px-4 pb-2">
|
<AccordionContent className="px-4 pb-2">
|
||||||
<div className="space-y-2 pt-2">
|
<div className="space-y-2 pt-2">
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">End Time:</span>
|
<span className="text-muted-foreground">End Time:</span>
|
||||||
<span>
|
<span>
|
||||||
{record.end_time
|
{record.end_time
|
||||||
? formatDate(record.end_time)
|
? formatDate(record.end_time)
|
||||||
@@ -1360,15 +1360,15 @@ export function DataManagement() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Products:</span>
|
<span className="text-muted-foreground">Products:</span>
|
||||||
<span>{record.processed_products} of {record.total_products}</span>
|
<span>{record.processed_products} of {record.total_products}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Orders:</span>
|
<span className="text-muted-foreground">Orders:</span>
|
||||||
<span>{record.processed_orders} of {record.total_orders}</span>
|
<span>{record.processed_orders} of {record.total_orders}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between text-sm">
|
<div className="flex justify-between text-sm">
|
||||||
<span className="text-gray-600">Purchase Orders:</span>
|
<span className="text-muted-foreground">Purchase Orders:</span>
|
||||||
<span>{record.processed_purchase_orders} of {record.total_purchase_orders}</span>
|
<span>{record.processed_purchase_orders} of {record.total_purchase_orders}</span>
|
||||||
</div>
|
</div>
|
||||||
{record.error_message && (
|
{record.error_message && (
|
||||||
|
|||||||
@@ -89,21 +89,6 @@ export function GlobalSettings() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
} else if (setting.setting_key === 'default_forecast_method') {
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
value={setting.setting_value}
|
|
||||||
onValueChange={(value) => updateSetting(setting.setting_key, value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select forecast method" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="standard">Standard</SelectItem>
|
|
||||||
<SelectItem value="seasonal">Seasonal</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
);
|
|
||||||
} else if (setting.setting_key.includes('threshold')) {
|
} else if (setting.setting_key.includes('threshold')) {
|
||||||
// Percentage inputs
|
// Percentage inputs
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import config from '../../config';
|
import config from '../../config';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
@@ -25,7 +24,6 @@ interface ProductSetting {
|
|||||||
lead_time_days: number | null;
|
lead_time_days: number | null;
|
||||||
days_of_stock: number | null;
|
days_of_stock: number | null;
|
||||||
safety_stock: number;
|
safety_stock: number;
|
||||||
forecast_method: string | null;
|
|
||||||
exclude_from_forecast: boolean;
|
exclude_from_forecast: boolean;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
product_name?: string; // Added for display purposes
|
product_name?: string; // Added for display purposes
|
||||||
@@ -86,7 +84,6 @@ export function ProductSettings() {
|
|||||||
lead_time_days: setting.lead_time_days,
|
lead_time_days: setting.lead_time_days,
|
||||||
days_of_stock: setting.days_of_stock,
|
days_of_stock: setting.days_of_stock,
|
||||||
safety_stock: setting.safety_stock,
|
safety_stock: setting.safety_stock,
|
||||||
forecast_method: setting.forecast_method,
|
|
||||||
exclude_from_forecast: setting.exclude_from_forecast
|
exclude_from_forecast: setting.exclude_from_forecast
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -180,7 +177,7 @@ export function ProductSettings() {
|
|||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search products by ID or name..."
|
placeholder="Search products by ID or name..."
|
||||||
className="pl-8"
|
className="pl-8 rounded-full"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@@ -195,7 +192,6 @@ export function ProductSettings() {
|
|||||||
<TableHead>Lead Time (days)</TableHead>
|
<TableHead>Lead Time (days)</TableHead>
|
||||||
<TableHead>Days of Stock</TableHead>
|
<TableHead>Days of Stock</TableHead>
|
||||||
<TableHead>Safety Stock</TableHead>
|
<TableHead>Safety Stock</TableHead>
|
||||||
<TableHead>Forecast Method</TableHead>
|
|
||||||
<TableHead>Exclude</TableHead>
|
<TableHead>Exclude</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -231,21 +227,6 @@ export function ProductSettings() {
|
|||||||
onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)}
|
onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<Select
|
|
||||||
value={setting.forecast_method || 'default'}
|
|
||||||
onValueChange={(value) => updateSetting(setting.pid, 'forecast_method', value === 'default' ? null : value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-28">
|
|
||||||
<SelectValue placeholder="Default" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="default">Default</SelectItem>
|
|
||||||
<SelectItem value="standard">Standard</SelectItem>
|
|
||||||
<SelectItem value="seasonal">Seasonal</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Switch
|
<Switch
|
||||||
checked={setting.exclude_from_forecast}
|
checked={setting.exclude_from_forecast}
|
||||||
|
|||||||
@@ -543,7 +543,7 @@ export function PromptManagement() {
|
|||||||
placeholder="Search prompts..."
|
placeholder="Search prompts..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="max-w-sm"
|
className="max-w-sm rounded-full px-3.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -568,7 +568,7 @@ export function PromptManagement() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow key={row.id} className="hover:bg-gray-100">
|
<TableRow key={row.id} className="hover:bg-muted">
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id} className="pl-3 whitespace-nowrap">
|
<TableCell key={cell.id} className="pl-3 whitespace-nowrap">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
|||||||
@@ -593,7 +593,7 @@ export function ReusableImageManagement() {
|
|||||||
placeholder="Search images..."
|
placeholder="Search images..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="max-w-sm"
|
className="max-w-sm rounded-full px-3.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -621,7 +621,7 @@ export function ReusableImageManagement() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow key={row.id} className="hover:bg-gray-100">
|
<TableRow key={row.id} className="hover:bg-muted">
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id} className="pl-6">
|
<TableCell key={cell.id} className="pl-6">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
|||||||
@@ -276,7 +276,7 @@ export function TemplateManagement() {
|
|||||||
placeholder="Search templates..."
|
placeholder="Search templates..."
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="max-w-sm"
|
className="max-w-sm rounded-full px-3.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -304,7 +304,7 @@ export function TemplateManagement() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel().rows?.length ? (
|
{table.getRowModel().rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
table.getRowModel().rows.map((row) => (
|
||||||
<TableRow key={row.id} className="hover:bg-gray-100">
|
<TableRow key={row.id} className="hover:bg-muted">
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell key={cell.id} className="pl-6">
|
<TableCell key={cell.id} className="pl-6">
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Username</FormLabel>
|
<FormLabel>Username</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input {...field} autoComplete="off" />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
@@ -290,10 +290,11 @@ export function UserForm({ user, permissions, onSave, onCancel }: UserFormProps)
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{user ? "New Password" : "Password"}</FormLabel>
|
<FormLabel>{user ? "New Password" : "Password"}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
type="password"
|
type="password"
|
||||||
{...field}
|
autoComplete="new-password"
|
||||||
placeholder={user ? "Leave blank to keep current password" : ""}
|
{...field}
|
||||||
|
placeholder={user ? "Leave blank to keep current password" : ""}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export function UserList({ users, onEdit, onDelete }: UserListProps) {
|
|||||||
{user.is_active ? (
|
{user.is_active ? (
|
||||||
<Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
|
<Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary" className="bg-slate-100">Inactive</Badge>
|
<Badge variant="secondary" className="bg-muted">Inactive</Badge>
|
||||||
)}
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { UserList } from "./UserList";
|
import { UserList } from "./UserList";
|
||||||
import { UserForm } from "./UserForm";
|
import { UserForm } from "./UserForm";
|
||||||
|
import { toast } from "sonner";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
import { AuthContext } from "@/contexts/AuthContext";
|
import { AuthContext } from "@/contexts/AuthContext";
|
||||||
import { ShieldAlert } from "lucide-react";
|
import { ShieldAlert } from "lucide-react";
|
||||||
@@ -241,7 +242,16 @@ export function UserManagement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("Server response after saving user:", responseData);
|
console.log("Server response after saving user:", responseData);
|
||||||
|
|
||||||
|
// Explicitly confirm whether a password was part of this save — the form
|
||||||
|
// silently drops an empty password on edit, so without this there is no
|
||||||
|
// way to tell if a password change actually went through.
|
||||||
|
toast.success(
|
||||||
|
userData.id
|
||||||
|
? (formattedUserData.password ? "User updated — password changed" : "User updated (password not changed)")
|
||||||
|
: "User created"
|
||||||
|
);
|
||||||
|
|
||||||
// Reset the form state
|
// Reset the form state
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
setIsAddingUser(false);
|
setIsAddingUser(false);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import { useDebounce } from '@/hooks/useDebounce';
|
import { useDebounce } from '@/hooks/useDebounce';
|
||||||
import { Search } from "lucide-react";
|
import { Search } from "lucide-react";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { toast } from "sonner";
|
||||||
import config from "@/config";
|
import config from "@/config";
|
||||||
|
|
||||||
interface VendorSetting {
|
interface VendorSetting {
|
||||||
@@ -34,7 +34,6 @@ export function VendorSettings() {
|
|||||||
const [searchInputValue, setSearchInputValue] = useState('');
|
const [searchInputValue, setSearchInputValue] = useState('');
|
||||||
const searchQuery = useDebounce(searchInputValue, 300); // 300ms debounce
|
const searchQuery = useDebounce(searchInputValue, 300); // 300ms debounce
|
||||||
const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({});
|
const [pendingChanges, setPendingChanges] = useState<Record<string, boolean>>({});
|
||||||
const { toast } = useToast();
|
|
||||||
|
|
||||||
// Use useCallback to avoid unnecessary re-renders
|
// Use useCallback to avoid unnecessary re-renders
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
@@ -50,15 +49,11 @@ export function VendorSettings() {
|
|||||||
setSettings(data.items);
|
setSettings(data.items);
|
||||||
setTotalCount(data.total);
|
setTotalCount(data.total);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast.error(`Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
title: "Error",
|
|
||||||
description: `Failed to load settings: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [page, searchQuery, pageSize, toast]);
|
}, [page, searchQuery, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
@@ -93,19 +88,12 @@ export function VendorSettings() {
|
|||||||
throw new Error(data.error || 'Failed to update vendor setting');
|
throw new Error(data.error || 'Failed to update vendor setting');
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast.success(`Settings updated for vendor ${vendor}`);
|
||||||
title: "Success",
|
|
||||||
description: `Settings updated for vendor ${vendor}`,
|
|
||||||
});
|
|
||||||
setPendingChanges(prev => ({ ...prev, [vendor]: false }));
|
setPendingChanges(prev => ({ ...prev, [vendor]: false }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast.error(`Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
title: "Error",
|
|
||||||
description: `Failed to update setting: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [settings, toast]);
|
}, [settings]);
|
||||||
|
|
||||||
const handleResetToDefault = useCallback(async (vendor: string) => {
|
const handleResetToDefault = useCallback(async (vendor: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -119,19 +107,12 @@ export function VendorSettings() {
|
|||||||
throw new Error(data.error || 'Failed to reset vendor setting');
|
throw new Error(data.error || 'Failed to reset vendor setting');
|
||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast.success(`Settings reset for vendor ${vendor}`);
|
||||||
title: "Success",
|
|
||||||
description: `Settings reset for vendor ${vendor}`,
|
|
||||||
});
|
|
||||||
loadSettings(); // Reload settings to get defaults
|
loadSettings(); // Reload settings to get defaults
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast.error(`Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
title: "Error",
|
|
||||||
description: `Failed to reset setting: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [loadSettings, toast]);
|
}, [loadSettings]);
|
||||||
|
|
||||||
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
|
const totalPages = useMemo(() => Math.ceil(totalCount / pageSize), [totalCount, pageSize]);
|
||||||
|
|
||||||
@@ -191,7 +172,7 @@ export function VendorSettings() {
|
|||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Search vendors..."
|
placeholder="Search vendors..."
|
||||||
className="pl-8"
|
className="pl-8 rounded-full"
|
||||||
value={searchInputValue}
|
value={searchInputValue}
|
||||||
onChange={(e) => setSearchInputValue(e.target.value)}
|
onChange={(e) => setSearchInputValue(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* Shared app-wide UI components (Sorbet Studio design system).
|
||||||
|
*
|
||||||
|
* Neutral import path for the design-system primitives that grew up in the
|
||||||
|
* dashboard and are now app-wide conventions. The implementation files still
|
||||||
|
* live in components/dashboard/shared/ (single physical source); import from
|
||||||
|
* HERE outside the dashboard so pages don't depend on a dashboard path.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* import { SectionHeader, StatCard, DashboardTable, TABLE_STYLES } from "@/components/shared";
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
DashboardSectionHeader,
|
||||||
|
DashboardSectionHeader as SectionHeader,
|
||||||
|
DashboardSectionHeaderSkeleton,
|
||||||
|
type DashboardSectionHeaderProps,
|
||||||
|
} from "@/components/dashboard/shared/DashboardSectionHeader";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DashboardEmptyState,
|
||||||
|
DashboardErrorState,
|
||||||
|
DashboardLoadingState,
|
||||||
|
type DashboardEmptyStateProps,
|
||||||
|
type DashboardErrorStateProps,
|
||||||
|
} from "@/components/dashboard/shared/DashboardStates";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DashboardBadge,
|
||||||
|
StatusBadge,
|
||||||
|
TrendBadge,
|
||||||
|
type BadgeVariant,
|
||||||
|
type StatusType,
|
||||||
|
} from "@/components/dashboard/shared/DashboardBadge";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DashboardTable,
|
||||||
|
SimpleTable,
|
||||||
|
type DashboardTableProps,
|
||||||
|
type TableColumn,
|
||||||
|
type SimpleTableProps,
|
||||||
|
} from "@/components/dashboard/shared/DashboardTable";
|
||||||
|
|
||||||
|
export {
|
||||||
|
MetricPill,
|
||||||
|
LegendChip,
|
||||||
|
QuietStat,
|
||||||
|
PILL_TRIGGER_CLASS,
|
||||||
|
PILL_BUTTON_CLASS,
|
||||||
|
} from "@/components/dashboard/shared/StudioControls";
|
||||||
|
|
||||||
|
export {
|
||||||
|
DashboardStatCard,
|
||||||
|
DashboardStatCard as StatCard,
|
||||||
|
DashboardStatCardSkeleton,
|
||||||
|
TrendPill,
|
||||||
|
type DashboardStatCardProps,
|
||||||
|
} from "@/components/dashboard/shared/DashboardStatCard";
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartSkeleton,
|
||||||
|
TableSkeleton,
|
||||||
|
StatCardSkeleton,
|
||||||
|
} from "@/components/dashboard/shared/DashboardSkeleton";
|
||||||
|
|
||||||
|
export {
|
||||||
|
CARD_STYLES,
|
||||||
|
TYPOGRAPHY,
|
||||||
|
SPACING,
|
||||||
|
MOTION,
|
||||||
|
SCROLL_STYLES,
|
||||||
|
TREND_COLORS,
|
||||||
|
FINANCIAL_COLORS,
|
||||||
|
METRIC_COLORS,
|
||||||
|
METRIC_COLORS_HSL,
|
||||||
|
STATUS_COLORS,
|
||||||
|
STAT_ICON_STYLES,
|
||||||
|
TABLE_STYLES,
|
||||||
|
BADGE_STYLES,
|
||||||
|
getTrendColor,
|
||||||
|
getIconColorVariant,
|
||||||
|
} from "@/lib/dashboard/designTokens";
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user