Compare commits
3 Commits
cde8148196
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e4a09ee3f | |||
| 6c973f0a9d | |||
| d779228485 |
@@ -12,13 +12,20 @@ export function createAuthRoutes({ pool }) {
|
||||
// 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.
|
||||
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 {
|
||||
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(
|
||||
'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1',
|
||||
[decoded.userId]
|
||||
@@ -29,7 +36,8 @@ export function createAuthRoutes({ pool }) {
|
||||
req.user = result.rows[0];
|
||||
next();
|
||||
} 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(
|
||||
{ userId: user.id, username: user.username },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '8h' }
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
const permissions = await getUserPermissions(user.id);
|
||||
res.json({
|
||||
|
||||
@@ -39,6 +39,11 @@ logger.info({
|
||||
const app = express();
|
||||
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({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
|
||||
@@ -38,26 +38,54 @@ const getImageUrls = (pid, iid = 1) => {
|
||||
};
|
||||
};
|
||||
|
||||
// Main stats endpoint - replaces /api/klaviyo/events/stats
|
||||
router.get('/stats', async (req, res) => {
|
||||
const startTime = Date.now();
|
||||
console.log(`[STATS] Starting request for timeRange: ${req.query.timeRange}`);
|
||||
|
||||
// Set a timeout for the entire operation
|
||||
const timeoutPromise = new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error('Request timeout after 15 seconds')), 15000);
|
||||
});
|
||||
|
||||
try {
|
||||
const mainOperation = async () => {
|
||||
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
|
||||
const excludeCB = parseBoolParam(excludeCherryBox);
|
||||
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 {
|
||||
// The DB sits across an SSH tunnel (~190ms per round trip; the queries
|
||||
// themselves are ~5ms on the server), so /stats response time is dominated by
|
||||
// round-trip count, not query cost. Two mitigations:
|
||||
// 1. computeStats runs its queries over several pooled connections (mysql2
|
||||
// serializes per connection), capped so concurrent refreshes can't starve
|
||||
// the 20-slot pool.
|
||||
// 2. Responses are cached per param-set and served stale-while-revalidate:
|
||||
// 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;
|
||||
const STATS_CACHE_TTL = 60 * 1000; // younger than this: serve as-is
|
||||
const STATS_CACHE_MAX_STALE = 15 * 60 * 1000; // older than this: block on refresh
|
||||
const statsCache = new Map(); // cacheKey -> { data, timestamp }
|
||||
const statsInflight = new Map(); // cacheKey -> Promise<response>
|
||||
|
||||
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 isSingleDay = ['today', 'yesterday'].includes(timeRange);
|
||||
|
||||
// Main order stats query (optionally excludes Cherry Box orders)
|
||||
// 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}
|
||||
`;
|
||||
|
||||
const [mainStats] = await connection.execute(mainStatsQuery, params);
|
||||
const stats = mainStats[0];
|
||||
|
||||
// Cancelled orders query - uses date_cancelled instead of date_placed
|
||||
// Shows orders cancelled during the selected period, regardless of when they were placed
|
||||
const cancelledQuery = `
|
||||
@@ -90,9 +115,6 @@ router.get('/stats', async (req, res) => {
|
||||
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)
|
||||
const refundsQuery = `
|
||||
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')}
|
||||
`;
|
||||
|
||||
const [refundStats] = await connection.execute(refundsQuery, params);
|
||||
|
||||
// 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
|
||||
const shippedQuery = `
|
||||
@@ -115,9 +135,6 @@ router.get('/stats', async (req, res) => {
|
||||
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)
|
||||
const bestDayQuery = `
|
||||
SELECT
|
||||
@@ -131,78 +148,38 @@ router.get('/stats', async (req, res) => {
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const [bestDayResult] = await connection.execute(bestDayQuery, params);
|
||||
|
||||
// Peak hour query - uses selected time range for the card value.
|
||||
// date_placed stores Central wall-clock; the office reads the axis in
|
||||
// Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
|
||||
let peakHour = null;
|
||||
if (['today', 'yesterday'].includes(timeRange)) {
|
||||
const peakHourQuery = `
|
||||
SELECT
|
||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||
COUNT(*) as count
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
||||
ORDER BY count DESC
|
||||
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')
|
||||
};
|
||||
}
|
||||
}
|
||||
// Only run for single-day ranges (see tasks below).
|
||||
const peakHourQuery = `
|
||||
SELECT
|
||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||
COUNT(*) as count
|
||||
FROM _order
|
||||
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
|
||||
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
||||
ORDER BY count DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
// 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]
|
||||
let hourlyOrders = null;
|
||||
if (['today', 'yesterday'].includes(timeRange)) {
|
||||
// Hourly counts in EASTERN display hours (column stores Central
|
||||
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same
|
||||
// expression so the rolling window stays aligned.
|
||||
const hourlyQuery = `
|
||||
SELECT
|
||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||
COUNT(*) as count,
|
||||
HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
|
||||
FROM _order
|
||||
WHERE order_status > 15
|
||||
AND ${getCherryBoxClause(excludeCB)}
|
||||
AND date_placed >= NOW() - INTERVAL 24 HOUR
|
||||
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
|
||||
`;
|
||||
// Hourly counts in EASTERN display hours (column stores Central
|
||||
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same
|
||||
// expression so the rolling window stays aligned.
|
||||
const hourlyQuery = `
|
||||
SELECT
|
||||
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
|
||||
COUNT(*) as count,
|
||||
HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
|
||||
FROM _order
|
||||
WHERE order_status > 15
|
||||
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
|
||||
// Only include products that have a brand assigned (INNER JOIN)
|
||||
const brandsQuery = `
|
||||
@@ -223,8 +200,6 @@ router.get('/stats', async (req, res) => {
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
const [brandsResult] = await connection.execute(brandsQuery, params);
|
||||
|
||||
// Categories query - uses product_category_index to get category assignments
|
||||
// Only include categories with valid types (no NULL/uncategorized)
|
||||
const categoriesQuery = `
|
||||
@@ -249,8 +224,6 @@ router.get('/stats', async (req, res) => {
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
const [categoriesResult] = await connection.execute(categoriesQuery, params);
|
||||
|
||||
// Shipping locations query - uses date_shipped to match shippedCount
|
||||
const shippingQuery = `
|
||||
SELECT
|
||||
@@ -265,11 +238,6 @@ router.get('/stats', async (req, res) => {
|
||||
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)
|
||||
// Excludes $0 orders from min calculation
|
||||
const orderRangeQuery = `
|
||||
@@ -280,17 +248,78 @@ router.get('/stats', async (req, res) => {
|
||||
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
|
||||
let periodProgress = 100;
|
||||
if (['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
|
||||
periodProgress = calculatePeriodProgress(timeRange);
|
||||
}
|
||||
|
||||
// Previous period comparison data
|
||||
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
|
||||
|
||||
|
||||
const response = {
|
||||
timeRange: dateRange,
|
||||
stats: {
|
||||
@@ -384,27 +413,57 @@ router.get('/stats', async (req, res) => {
|
||||
};
|
||||
|
||||
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`);
|
||||
res.json(response);
|
||||
// Single-flight refresh: concurrent misses for the same key share one
|
||||
// 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) {
|
||||
if (error.message.includes('timeout')) {
|
||||
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
|
||||
} else {
|
||||
console.error('Error in /stats:', error);
|
||||
}
|
||||
console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
@@ -812,23 +871,15 @@ router.get('/products', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Projection endpoint - replaces /api/klaviyo/events/projection
|
||||
router.get('/projection', async (req, res) => {
|
||||
// Same stale-while-revalidate treatment as /stats: several tunnel round trips
|
||||
// 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();
|
||||
let release;
|
||||
const { connection, release } = await getDbConnection();
|
||||
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);
|
||||
|
||||
@@ -877,15 +928,52 @@ router.get('/projection', async (req, res) => {
|
||||
projection.currentRevenue = parseFloat(current.currentRevenue || 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)}`);
|
||||
res.json(projection);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error);
|
||||
res.status(500).json({ error: error.message });
|
||||
console.log(`[PROJECTION] Computed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
|
||||
return projection;
|
||||
} finally {
|
||||
// Release connection back to pool
|
||||
if (release) 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 });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -314,7 +314,6 @@ async function syncSettingsProductTable() {
|
||||
lead_time_days,
|
||||
days_of_stock,
|
||||
safety_stock,
|
||||
forecast_method,
|
||||
exclude_from_forecast
|
||||
)
|
||||
SELECT
|
||||
@@ -322,7 +321,6 @@ async function syncSettingsProductTable() {
|
||||
CAST(NULL AS INTEGER),
|
||||
CAST(NULL AS INTEGER),
|
||||
COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0),
|
||||
CAST(NULL AS VARCHAR),
|
||||
FALSE
|
||||
FROM
|
||||
public.products p
|
||||
|
||||
@@ -136,7 +136,7 @@ router.put('/products/:pid', async (req, res) => {
|
||||
const pool = req.app.locals.pool;
|
||||
try {
|
||||
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);
|
||||
|
||||
@@ -149,10 +149,10 @@ router.put('/products/:pid', async (req, res) => {
|
||||
if (checkProduct.length === 0) {
|
||||
// Insert if it doesn't exist
|
||||
await pool.query(
|
||||
`INSERT INTO settings_product
|
||||
(pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast]
|
||||
`INSERT INTO settings_product
|
||||
(pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast]
|
||||
);
|
||||
} else {
|
||||
// Update if it exists
|
||||
@@ -161,11 +161,10 @@ router.put('/products/:pid', async (req, res) => {
|
||||
SET lead_time_days = $2,
|
||||
days_of_stock = $3,
|
||||
safety_stock = $4,
|
||||
forecast_method = $5,
|
||||
exclude_from_forecast = $6,
|
||||
exclude_from_forecast = $5,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
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,
|
||||
days_of_stock = NULL,
|
||||
safety_stock = 0,
|
||||
forecast_method = NULL,
|
||||
exclude_from_forecast = false,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
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
|
||||
router.get('/check-upc-and-generate-sku', async (req, res) => {
|
||||
const { upc, supplierId } = req.query;
|
||||
|
||||
+15
-10
@@ -36,6 +36,7 @@ const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
|
||||
// 2. Dashboard app - separate chunk
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
|
||||
const MobileDashboard = lazy(() => import('./pages/MobileDashboard'));
|
||||
|
||||
// 3. Product import - separate chunk
|
||||
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');
|
||||
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)}`);
|
||||
}
|
||||
} else {
|
||||
} else if (response.ok) {
|
||||
// If token is valid, set the login flag
|
||||
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) {
|
||||
// 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);
|
||||
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 />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route path="/mobile" element={
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<MobileDashboard />
|
||||
</Suspense>
|
||||
} />
|
||||
<Route element={
|
||||
<RequireAuth>
|
||||
<MainLayout />
|
||||
|
||||
@@ -163,17 +163,17 @@ export function AiDescriptionCompare({
|
||||
</div>
|
||||
|
||||
{/* 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) */}
|
||||
<div ref={aiHeaderRef} className="flex-shrink-0">
|
||||
{/* Header */}
|
||||
<div className="w-full flex items-center justify-between px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
</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"})
|
||||
</span>
|
||||
</div>
|
||||
@@ -185,7 +185,7 @@ export function AiDescriptionCompare({
|
||||
{issues.map((issue, index) => (
|
||||
<div
|
||||
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" />
|
||||
<span>{issue}</span>
|
||||
@@ -199,7 +199,7 @@ export function AiDescriptionCompare({
|
||||
<div className="px-3 pb-3 flex flex-col flex-1 gap-3">
|
||||
{/* Editable suggestion */}
|
||||
<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):
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -209,7 +209,7 @@ export function AiDescriptionCompare({
|
||||
setEditedSuggestion(e.target.value);
|
||||
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>
|
||||
|
||||
@@ -218,7 +218,7 @@ export function AiDescriptionCompare({
|
||||
<Button
|
||||
size="sm"
|
||||
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)}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
@@ -227,7 +227,7 @@ export function AiDescriptionCompare({
|
||||
<Button
|
||||
size="sm"
|
||||
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}
|
||||
>
|
||||
Ignore
|
||||
@@ -236,7 +236,7 @@ export function AiDescriptionCompare({
|
||||
<Button
|
||||
size="sm"
|
||||
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}
|
||||
onClick={onRevalidate}
|
||||
>
|
||||
|
||||
@@ -209,7 +209,7 @@ export function BulkEditRow({
|
||||
{state.result!.issues.map((issue, i) => (
|
||||
<div
|
||||
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" />
|
||||
<span>{issue}</span>
|
||||
@@ -223,12 +223,12 @@ export function BulkEditRow({
|
||||
<Input
|
||||
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||
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
|
||||
size="sm"
|
||||
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={() =>
|
||||
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||
}
|
||||
@@ -239,7 +239,7 @@ export function BulkEditRow({
|
||||
<Button
|
||||
size="sm"
|
||||
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)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
@@ -254,11 +254,11 @@ export function BulkEditRow({
|
||||
if (!showSuggestion) return null;
|
||||
|
||||
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 */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<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
|
||||
</span>
|
||||
{state.result!.issues.length > 0 && (
|
||||
@@ -274,7 +274,7 @@ export function BulkEditRow({
|
||||
{state.result!.issues.map((issue, i) => (
|
||||
<div
|
||||
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" />
|
||||
<span>{issue}</span>
|
||||
@@ -287,7 +287,7 @@ export function BulkEditRow({
|
||||
<Textarea
|
||||
value={state.editedSuggestion ?? state.result!.suggestion!}
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -296,7 +296,7 @@ export function BulkEditRow({
|
||||
<Button
|
||||
size="sm"
|
||||
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={() =>
|
||||
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
|
||||
}
|
||||
@@ -307,7 +307,7 @@ export function BulkEditRow({
|
||||
<Button
|
||||
size="sm"
|
||||
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)}
|
||||
>
|
||||
Dismiss
|
||||
|
||||
@@ -210,7 +210,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
case 'd':
|
||||
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
||||
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 (
|
||||
<div className="mt-2 space-y-2">
|
||||
{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">
|
||||
<ExternalLink className="h-4 w-4 mt-1 text-blue-500 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -335,7 +335,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
|
||||
};
|
||||
|
||||
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">
|
||||
{isImage ? (
|
||||
<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">
|
||||
<Input
|
||||
placeholder="Search messages..."
|
||||
className="rounded-full px-3.5"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && searchMessages()}
|
||||
|
||||
@@ -62,7 +62,7 @@ export function ChatTest({ selectedUserId }: ChatTestProps) {
|
||||
case 'd':
|
||||
return <MessageSquare className="h-4 w-4 text-green-500" />;
|
||||
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) => (
|
||||
<div
|
||||
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">
|
||||
{getRoomIcon(room.type)}
|
||||
|
||||
@@ -193,8 +193,8 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="h-12 w-12 bg-gray-50 rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-gray-500" />
|
||||
<div className="h-12 w-12 bg-surface-subtle rounded-full flex items-center justify-center">
|
||||
<Users className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -268,7 +268,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
||||
|
||||
return (
|
||||
<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}
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{title} ({rooms.length})
|
||||
@@ -281,7 +281,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
|
||||
onClick={() => onRoomSelect(room.id.toString())}
|
||||
className={`
|
||||
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' : ''}
|
||||
${(room.open === false || room.archived === true) ? 'opacity-60' : ''}
|
||||
`}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function SearchResults({ results, query, onClose, onRoomSelect }: SearchR
|
||||
case 'd':
|
||||
return <MessageSquare className="h-3 w-3 text-green-500" />;
|
||||
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) => (
|
||||
<div
|
||||
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={() => {
|
||||
onRoomSelect(result.room_id.toString());
|
||||
onClose();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from "react";
|
||||
import { useNow } from "@/hooks/useNow";
|
||||
|
||||
const formatDate = (date) =>
|
||||
date.toLocaleDateString("en-US", {
|
||||
@@ -15,7 +16,7 @@ const greeting = (date) => {
|
||||
};
|
||||
|
||||
const Header = ({ right = null }) => {
|
||||
const now = new Date();
|
||||
const now = useNow();
|
||||
return (
|
||||
<header className="px-3 pt-5 sm:px-4 lg:px-5">
|
||||
<div className="flex items-center justify-between gap-4 pb-1">
|
||||
|
||||
@@ -1268,14 +1268,18 @@ const StatCards = ({
|
||||
let isMounted = true;
|
||||
|
||||
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 {
|
||||
setProjectionLoading(true);
|
||||
const params =
|
||||
timeRange === "custom" ? { startDate, endDate } : { timeRange };
|
||||
|
||||
const response = await acotService.getProjection(params);
|
||||
const response = await acotService.getProjection({ timeRange });
|
||||
|
||||
if (!isMounted) return;
|
||||
setProjection(response);
|
||||
@@ -1292,7 +1296,7 @@ const StatCards = ({
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [timeRange, startDate, endDate, stats?.periodProgress]);
|
||||
}, [timeRange]);
|
||||
|
||||
// Auto-refresh for 'today' view
|
||||
useEffect(() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -71,7 +71,7 @@ const trendPct = (cur: number, prev: number) =>
|
||||
// ── tiny presentational atoms ────────────────────────────────────────────────
|
||||
const Lbl = ({ children }: { children: React.ReactNode }) => (
|
||||
<div
|
||||
className="text-[15px] font-medium uppercase"
|
||||
className="text-[17px] font-medium uppercase"
|
||||
style={{ color: NB.mut, letterSpacing: "0.22em" }}
|
||||
>
|
||||
{children}
|
||||
@@ -197,7 +197,7 @@ const Clock = () => {
|
||||
return () => clearInterval(t);
|
||||
}, []);
|
||||
return (
|
||||
<span className="text-[17px]" style={{ color: NB.mut, letterSpacing: "0.06em" }}>
|
||||
<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 }}>
|
||||
@@ -223,7 +223,6 @@ const NightboardSmall = () => {
|
||||
queryKey: ["nightboard-projection-today"],
|
||||
queryFn: async () =>
|
||||
(await acotService.getProjection({ timeRange: "today" })) as Projection,
|
||||
enabled: stats != null && stats.periodProgress < 100,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
@@ -274,7 +273,6 @@ const NightboardSmall = () => {
|
||||
queryKey: ["nightboard-projection-30d"],
|
||||
queryFn: async () =>
|
||||
(await acotService.getProjection({ timeRange: "last30days" })) as Projection,
|
||||
enabled: sum30 != null && sum30.periodProgress < 100,
|
||||
refetchInterval: 300_000,
|
||||
});
|
||||
|
||||
@@ -422,6 +420,13 @@ const NightboardSmall = () => {
|
||||
(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
|
||||
@@ -549,7 +554,7 @@ const NightboardSmall = () => {
|
||||
{/* top line */}
|
||||
<div className="flex items-baseline justify-between pb-6">
|
||||
<span
|
||||
className="text-[16px] font-medium uppercase"
|
||||
className="text-[18px] font-medium uppercase"
|
||||
style={{ color: NB.mut, letterSpacing: "0.3em" }}
|
||||
>
|
||||
A Cherry On Top
|
||||
@@ -567,42 +572,42 @@ const NightboardSmall = () => {
|
||||
<div>
|
||||
<Lbl>Revenue today</Lbl>
|
||||
<div
|
||||
className="text-[104px] font-extralight leading-[0.95] mt-2"
|
||||
className="text-[112px] font-extralight leading-[0.95] mt-2"
|
||||
style={{ color: NB.amberText, letterSpacing: "-0.03em" }}
|
||||
>
|
||||
{fmtMoney(stats?.revenue)}
|
||||
</div>
|
||||
<div className="text-[18px] mt-3" style={{ color: NB.mut }}>
|
||||
<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 last week"}
|
||||
<TrendArrow pct={revTrend} /> {revTrend != null && "vs yesterday"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Lbl>Orders</Lbl>
|
||||
<div className="text-[52px] font-light leading-none mt-2">
|
||||
<div className="text-[58px] font-light leading-none mt-2">
|
||||
{stats?.orderCount?.toLocaleString() ?? "—"}
|
||||
</div>
|
||||
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
|
||||
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||
{projOrders != null && inProgress && (
|
||||
<>
|
||||
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{projOrders}</span>
|
||||
{" · "}
|
||||
</>
|
||||
)}
|
||||
{stats?.itemCount != null ? `${fmtCount(stats.itemCount)} items` : ""}
|
||||
<TrendArrow pct={ordersTrend} /> {ordersTrend != null && "vs yesterday"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Lbl>Avg order</Lbl>
|
||||
<div className="text-[52px] font-light leading-none mt-2">
|
||||
<div className="text-[58px] font-light leading-none mt-2">
|
||||
{stats?.averageOrderValue != null ? `$${stats.averageOrderValue.toFixed(2)}` : "—"}
|
||||
</div>
|
||||
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
|
||||
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||
{stats?.averageItemsPerOrder != null
|
||||
? `${stats.averageItemsPerOrder.toFixed(1)} items / order`
|
||||
: ""}
|
||||
@@ -610,7 +615,7 @@ const NightboardSmall = () => {
|
||||
</div>
|
||||
<div>
|
||||
<Lbl>On site now</Lbl>
|
||||
<div className="text-[52px] font-light leading-none mt-2 flex items-center gap-4">
|
||||
<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"
|
||||
@@ -620,7 +625,7 @@ const NightboardSmall = () => {
|
||||
</span>
|
||||
{realtime?.last5MinUsers ?? "—"}
|
||||
</div>
|
||||
<div className="text-[17px] mt-2" style={{ color: NB.mut }}>
|
||||
<div className="text-[19px] mt-2" style={{ color: NB.mut }}>
|
||||
{realtime != null ? `${realtime.last30MinUsers} last 30 min` : ""}
|
||||
</div>
|
||||
</div>
|
||||
@@ -630,7 +635,7 @@ const NightboardSmall = () => {
|
||||
<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-[17px]" style={{ color: NB.mut }}>
|
||||
<span className="text-[19px]" style={{ color: NB.mut }}>
|
||||
Revenue <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtCompact(sum30?.revenue)}</span>{" "}
|
||||
<TrendArrow pct={rev30Trend} />
|
||||
{" · "}
|
||||
@@ -652,17 +657,17 @@ const NightboardSmall = () => {
|
||||
tickFormatter={(v: string) =>
|
||||
new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" })
|
||||
}
|
||||
tick={{ fill: NB.mut, fontSize: 14 }}
|
||||
tick={{ fill: NB.mut, fontSize: 16 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
interval={5}
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v: number) => fmtCompact(v)}
|
||||
tick={{ fill: NB.mut, fontSize: 14 }}
|
||||
tick={{ fill: NB.mut, fontSize: 16 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={52}
|
||||
width={58}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ stroke: NB.faint, strokeDasharray: "3 4" }}
|
||||
@@ -671,7 +676,7 @@ const NightboardSmall = () => {
|
||||
const row = payload[0].payload as { date: string; total: number };
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg px-4 py-2.5 text-[15px]"
|
||||
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 }}>
|
||||
@@ -713,7 +718,7 @@ const NightboardSmall = () => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-5 text-[13px]" style={{ color: NB.mut }}>
|
||||
<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
|
||||
@@ -739,15 +744,15 @@ const NightboardSmall = () => {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="text-[13px] font-medium uppercase"
|
||||
className="text-[15px] font-medium uppercase"
|
||||
style={{ color: NB.mut, letterSpacing: "0.18em" }}
|
||||
>
|
||||
{cell.label}
|
||||
</div>
|
||||
<div className="text-[34px] font-light mt-1.5" style={{ letterSpacing: "-0.01em" }}>
|
||||
<div className="text-[38px] font-light mt-1.5" style={{ letterSpacing: "-0.01em" }}>
|
||||
{cell.value}
|
||||
</div>
|
||||
<div className="text-[15px] mt-1" style={{ color: NB.mut }}>
|
||||
<div className="text-[17px] mt-1" style={{ color: NB.mut }}>
|
||||
{cell.sub}
|
||||
</div>
|
||||
</div>
|
||||
@@ -811,11 +816,11 @@ const NightboardSmall = () => {
|
||||
return (
|
||||
<EventDialog key={event.id} event={event} scale={1.75}>
|
||||
<div
|
||||
className="w-[290px] shrink-0 rounded-2xl px-5 py-4 cursor-pointer transition-colors hover:brightness-125"
|
||||
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-[12px] font-medium uppercase"
|
||||
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">
|
||||
@@ -834,8 +839,8 @@ const NightboardSmall = () => {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[21px] font-medium mt-2 truncate">{name || "—"}</div>
|
||||
<div className="text-[16px] mt-1 truncate" style={{ color: NB.mut }}>
|
||||
<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>
|
||||
|
||||
@@ -50,7 +50,7 @@ export function BusinessRangeSelect({
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className={cn(
|
||||
"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] focus:ring-1 focus:ring-[#e06a4e]/30 focus:ring-offset-0",
|
||||
"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
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -112,22 +112,22 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
|
||||
}) => {
|
||||
const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3";
|
||||
const titleClass = size === "large"
|
||||
? "text-[14px] font-semibold tracking-tight text-[#2b2925]"
|
||||
: "text-[12.5px] font-semibold tracking-tight text-[#2b2925]";
|
||||
? "text-[14px] font-semibold tracking-tight 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
|
||||
if (loading) {
|
||||
return (
|
||||
<CardHeader className={cn("border-b border-[#f0eeea]", paddingClass, className)}>
|
||||
<CardHeader className={cn("border-b border-muted", paddingClass, className)}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-5 w-40 bg-[#f0eeea]" />
|
||||
{description && <Skeleton className="h-4 w-56 bg-[#f5f3f0]" />}
|
||||
<Skeleton className="h-5 w-40 bg-muted" />
|
||||
{description && <Skeleton className="h-4 w-56 bg-muted/60" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{timeSelector && <Skeleton className="h-8 w-[130px] bg-[#f0eeea] rounded-full" />}
|
||||
{actions && <Skeleton className="h-8 w-20 bg-[#f0eeea] rounded-full" />}
|
||||
{timeSelector && <Skeleton className="h-8 w-[130px] bg-muted rounded-full" />}
|
||||
{actions && <Skeleton className="h-8 w-20 bg-muted rounded-full" />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -137,14 +137,14 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
|
||||
const hasRightContent = timeSelector || actions;
|
||||
|
||||
return (
|
||||
<CardHeader className={cn("border-b border-[#f0eeea]", paddingClass, className)}>
|
||||
<CardHeader className={cn("border-b border-muted", paddingClass, className)}>
|
||||
<div className="flex flex-row items-center justify-between gap-3">
|
||||
{/* Left side: title (+ optional description / updated time inline) */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-baseline gap-x-2.5">
|
||||
<CardTitle className={titleClass}>{title}</CardTitle>
|
||||
{lastUpdated && !loading && (
|
||||
<span className="text-[10.5px] text-[#a09b92]">
|
||||
<span className="text-[10.5px] text-muted-foreground/75">
|
||||
{lastUpdatedFormat(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
@@ -191,15 +191,15 @@ export const DashboardSectionHeaderSkeleton: React.FC<DashboardSectionHeaderSkel
|
||||
const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3";
|
||||
|
||||
return (
|
||||
<CardHeader className={cn("border-b border-[#f0eeea]", paddingClass, className)}>
|
||||
<CardHeader className={cn("border-b border-muted", paddingClass, className)}>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-5 w-40 bg-[#f0eeea]" />
|
||||
{hasDescription && <Skeleton className="h-4 w-56 bg-[#f5f3f0]" />}
|
||||
<Skeleton className="h-5 w-40 bg-muted" />
|
||||
{hasDescription && <Skeleton className="h-4 w-56 bg-muted/60" />}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasTimeSelector && <Skeleton className="h-8 w-[130px] bg-[#f0eeea] rounded-full" />}
|
||||
{hasActions && <Skeleton className="h-8 w-20 bg-[#f0eeea] rounded-full" />}
|
||||
{hasTimeSelector && <Skeleton className="h-8 w-[130px] bg-muted rounded-full" />}
|
||||
{hasActions && <Skeleton className="h-8 w-20 bg-muted rounded-full" />}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -136,9 +136,9 @@ export const DashboardStatCard: React.FC<DashboardStatCardProps> = ({
|
||||
: "text-[20px]";
|
||||
|
||||
const cardClass = cn(
|
||||
"rounded-[14px] border bg-white px-3.5 py-3",
|
||||
"rounded-[14px] border bg-card px-3.5 py-3",
|
||||
onClick &&
|
||||
"cursor-pointer transition-colors duration-150 hover:bg-[#faf9f7] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"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 };
|
||||
@@ -147,9 +147,9 @@ export const DashboardStatCard: React.FC<DashboardStatCardProps> = ({
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={cn(cardClass, "min-h-[92px]")} style={cardStyle}>
|
||||
<div className="h-3.5 w-20 animate-pulse rounded bg-[#f0eeea]" />
|
||||
<div className="mt-2.5 h-6 w-28 animate-pulse rounded bg-[#f0eeea]" />
|
||||
{subtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-[#f5f3f0]" />}
|
||||
<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" />
|
||||
{subtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-muted/60" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -219,12 +219,12 @@ export const DashboardStatCardSkeleton: React.FC<DashboardStatCardSkeletonProps>
|
||||
className,
|
||||
}) => (
|
||||
<div
|
||||
className={cn("min-h-[92px] rounded-[14px] border bg-white px-3.5 py-3", className)}
|
||||
className={cn("min-h-[92px] rounded-[14px] border bg-card px-3.5 py-3", className)}
|
||||
style={{ borderColor: STUDIO_COLORS.border }}
|
||||
>
|
||||
<div className="h-3.5 w-20 animate-pulse rounded bg-[#f0eeea]" />
|
||||
<div className="mt-2.5 h-6 w-28 animate-pulse rounded bg-[#f0eeea]" />
|
||||
{hasSubtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-[#f5f3f0]" />}
|
||||
<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" />
|
||||
{hasSubtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-muted/60" />}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export const PILL_TRIGGER_CLASS =
|
||||
"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] focus:ring-1 focus:ring-[#e06a4e]/30 focus:ring-offset-0";
|
||||
"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-[#e7e5e1] bg-white px-3.5 text-xs font-semibold text-[#2b2925] shadow-none hover:bg-[#faf9f7]";
|
||||
"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;
|
||||
@@ -39,8 +39,8 @@ export const MetricPill: React.FC<MetricPillProps> = ({
|
||||
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-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]"
|
||||
: "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]"
|
||||
? "bg-muted text-foreground hover:bg-[#eae7e2]"
|
||||
: "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@@ -63,7 +63,7 @@ export interface LegendChipProps {
|
||||
|
||||
/** 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-[#7c7870]">
|
||||
<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={
|
||||
@@ -87,7 +87,7 @@ export interface QuietStatProps {
|
||||
/**
|
||||
* Deemphasized stat cell. Place inside:
|
||||
* <div className="grid grid-cols-2 gap-px overflow-hidden rounded-[14px]
|
||||
* border border-[#e7e5e1] bg-[#f0eeea] sm:grid-cols-3 lg:grid-cols-6">
|
||||
* 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";
|
||||
@@ -95,20 +95,20 @@ export const QuietStat: React.FC<QuietStatProps> = ({ label, value, sub, onClick
|
||||
<Tag
|
||||
{...(onClick ? { type: "button" as const, onClick } : {})}
|
||||
className={cn(
|
||||
"bg-white px-3.5 py-2.5 text-left",
|
||||
"bg-card px-3.5 py-2.5 text-left",
|
||||
onClick &&
|
||||
"transition-colors hover:bg-[#faf9f7] focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
"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-[#a09b92]">{label}</div>
|
||||
<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-[#f0eeea]" />
|
||||
<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-[#2b2925]">
|
||||
<div className="text-[15px] font-bold tabular-nums tracking-tight text-foreground">
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="text-[10.5px] text-[#7c7870]">{sub}</div>}
|
||||
{sub && <div className="text-[10.5px] text-muted-foreground">{sub}</div>}
|
||||
</>
|
||||
)}
|
||||
</Tag>
|
||||
@@ -116,4 +116,4 @@ export const QuietStat: React.FC<QuietStatProps> = ({ label, value, sub, onClick
|
||||
};
|
||||
|
||||
export const QUIET_STRIP_CLASS =
|
||||
"grid gap-px overflow-hidden rounded-[14px] border border-[#e7e5e1] bg-[#f0eeea]";
|
||||
"grid gap-px overflow-hidden rounded-[14px] border border-border bg-muted";
|
||||
|
||||
@@ -232,7 +232,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
||||
className={cn(
|
||||
"sticky left-0 z-10 border-r px-3 py-2",
|
||||
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">
|
||||
@@ -243,7 +243,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
||||
<span
|
||||
className={cn(
|
||||
"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}
|
||||
@@ -275,7 +275,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
||||
<TableCell
|
||||
key={`${row.key}-${bucket.key}`}
|
||||
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,
|
||||
isImportant && "font-semibold text-emerald-700"
|
||||
)}
|
||||
@@ -294,7 +294,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
||||
<TableCell
|
||||
key={`${row.key}-${bucket.key}`}
|
||||
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,
|
||||
isImportant && "font-semibold text-emerald-700"
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
@@ -62,17 +63,18 @@ export function BestSellers() {
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue="products">
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg font-medium">Best Sellers</CardTitle>
|
||||
<SectionHeader
|
||||
title="Best Sellers"
|
||||
size="large"
|
||||
actions={
|
||||
<TabsList>
|
||||
<TabsTrigger value="products">Products</TabsTrigger>
|
||||
<TabsTrigger value="brands">Brands</TabsTrigger>
|
||||
<TabsTrigger value="categories">Categories</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
}
|
||||
/>
|
||||
<CardContent className="pt-4">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load best sellers</p>
|
||||
) : isLoading ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 { useState } from "react"
|
||||
import config from "@/config"
|
||||
@@ -87,9 +88,10 @@ export function ForecastMetrics() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||
<CardTitle className="text-xl font-medium">Forecast</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<SectionHeader
|
||||
title="Forecast"
|
||||
size="large"
|
||||
actions={
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
@@ -100,6 +102,8 @@ export function ForecastMetrics() {
|
||||
<ForecastAccuracy />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
}
|
||||
timeSelector={
|
||||
<Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="30">30D</TabsTrigger>
|
||||
@@ -107,9 +111,9 @@ export function ForecastMetrics() {
|
||||
<TabsTrigger value="year">EOY</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
}
|
||||
/>
|
||||
<CardContent className="pt-3 pb-0 -mb-2">
|
||||
{error ? (
|
||||
<div className="text-sm text-red-500">Error: {error.message}</div>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { AlertTriangle, Layers, DollarSign, Tag } from "lucide-react"
|
||||
@@ -47,10 +48,8 @@ export function OverstockMetrics() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Overstock</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SectionHeader title="Overstock" size="large" />
|
||||
<CardContent className="pt-4">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load overstock metrics</p>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 config from "@/config"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
@@ -84,10 +85,8 @@ export function PurchaseMetrics() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Purchases</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SectionHeader title="Purchases" size="large" />
|
||||
<CardContent className="pt-4">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load purchase metrics</p>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 { formatCurrency } from "@/utils/formatCurrency"
|
||||
import { PackagePlus, DollarSign, Tag } from "lucide-react"
|
||||
@@ -49,10 +50,8 @@ export function ReplenishmentMetrics() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Replenishment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SectionHeader title="Replenishment" size="large" />
|
||||
<CardContent className="pt-4">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load replenishment metrics</p>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 { useState } from "react"
|
||||
import config from "@/config"
|
||||
@@ -72,11 +73,14 @@ export function SalesMetrics() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader className="flex flex-row items-center justify-between pr-5">
|
||||
<CardTitle className="text-xl font-medium">Sales</CardTitle>
|
||||
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} />
|
||||
</CardHeader>
|
||||
<CardContent className="py-0 -mb-2">
|
||||
<SectionHeader
|
||||
title="Sales"
|
||||
size="large"
|
||||
timeSelector={
|
||||
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} />
|
||||
}
|
||||
/>
|
||||
<CardContent className="pt-3 pb-0 -mb-2">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load sales metrics</p>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 config from "@/config"
|
||||
import { formatCurrency } from "@/utils/formatCurrency"
|
||||
@@ -116,10 +117,8 @@ export function StockMetrics() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Stock</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SectionHeader title="Stock" size="large" />
|
||||
<CardContent className="pt-4">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load stock metrics</p>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import config from "@/config"
|
||||
@@ -38,10 +39,8 @@ export function TopOverstockedProducts() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SectionHeader title="Top Overstocked Products" size="large" />
|
||||
<CardContent className="pt-4">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load overstocked products</p>
|
||||
) : isLoading ? (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import config from "@/config"
|
||||
@@ -38,10 +39,8 @@ export function TopReplenishProducts() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<SectionHeader title="Top Products To Replenish" size="large" />
|
||||
<CardContent className="pt-4">
|
||||
{isError ? (
|
||||
<p className="text-sm text-destructive">Failed to load replenish products</p>
|
||||
) : isLoading ? (
|
||||
|
||||
@@ -190,7 +190,7 @@ export function EditableMultiSelect({
|
||||
{/* Selected items pinned to top */}
|
||||
{value.length > 0 && (
|
||||
<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" />
|
||||
<span>Selected ({value.length})</span>
|
||||
</div>
|
||||
@@ -204,7 +204,7 @@ export function EditableMultiSelect({
|
||||
key={`sel-${selectedVal}`}
|
||||
value={`sel-${opt?.label ?? 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" />
|
||||
{hex && (
|
||||
@@ -226,7 +226,7 @@ export function EditableMultiSelect({
|
||||
{/* AI Suggestions section */}
|
||||
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
|
||||
<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" />
|
||||
<span>Suggested</span>
|
||||
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
@@ -242,7 +242,7 @@ export function EditableMultiSelect({
|
||||
key={`suggestion-${suggestion.id}`}
|
||||
value={`suggestion-${suggestion.name}`}
|
||||
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">
|
||||
<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)}
|
||||
</span>
|
||||
</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}%
|
||||
</span>
|
||||
</CommandItem>
|
||||
|
||||
@@ -703,7 +703,7 @@ export function ProductEditForm({
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -140,6 +140,7 @@ export function ProductSearch({
|
||||
<div className="flex gap-3">
|
||||
<Input
|
||||
placeholder="Search products…"
|
||||
className="rounded-full px-3.5"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
@@ -185,11 +186,11 @@ export function ProductSearch({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="sticky top-0 bg-background">Name</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Item Number</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Brand</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background">Line</TableHead>
|
||||
<TableHead className="sticky top-0 bg-background text-right">
|
||||
<TableHead className="sticky top-0 bg-card">Name</TableHead>
|
||||
<TableHead className="sticky top-0 bg-card">Item Number</TableHead>
|
||||
<TableHead className="sticky top-0 bg-card">Brand</TableHead>
|
||||
<TableHead className="sticky top-0 bg-card">Line</TableHead>
|
||||
<TableHead className="sticky top-0 bg-card text-right">
|
||||
Price
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
|
||||
+1
-1
@@ -70,7 +70,7 @@ export const GenericDropzone = ({
|
||||
e.stopPropagation();
|
||||
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'}
|
||||
</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 ${
|
||||
canCopy
|
||||
? isCopied
|
||||
? "bg-green-100 text-green-600 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400"
|
||||
: "text-muted-foreground hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
? "bg-green-100 text-green-600 hover:bg-green-200"
|
||||
: "text-muted-foreground hover:bg-gray-100"
|
||||
: "opacity-50 cursor-not-allowed"
|
||||
}`}
|
||||
disabled={!canCopy}
|
||||
|
||||
+1
-1
@@ -201,7 +201,7 @@ export const SortableImage = ({
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</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
|
||||
src={getFullImageUrl(image.url || image.imageUrl || '')}
|
||||
alt={`${displayName} - Image ${imgIndex + 1}`}
|
||||
|
||||
+2
-2
@@ -24,11 +24,11 @@ export const UnassignedImagesSection = ({
|
||||
|
||||
return (
|
||||
<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 gap-2">
|
||||
<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})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
+3
-3
@@ -43,7 +43,7 @@ export const UnassignedImageItem = ({
|
||||
<p className="text-white text-xs mb-1 truncate">{image.file.name}</p>
|
||||
<div className="flex gap-2">
|
||||
<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..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -57,7 +57,7 @@ export const UnassignedImageItem = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
onRemove(index);
|
||||
@@ -94,7 +94,7 @@ export const UnassignedImageItem = ({
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</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
|
||||
src={image.previewUrl}
|
||||
alt={`Unassigned image: ${image.file.name}`}
|
||||
|
||||
@@ -1510,7 +1510,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
type="checkbox"
|
||||
checked={!!globalSelections.costIsTotalCost}
|
||||
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
|
||||
</label>
|
||||
@@ -1561,7 +1561,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
|
||||
return (
|
||||
<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="text-center">
|
||||
{renderSamplePreview(column.index)}
|
||||
@@ -1944,7 +1944,7 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
) : (
|
||||
<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)}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
{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">
|
||||
<Check className="h-4 w-4 text-green-700 dark:text-green-500" />
|
||||
<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" />
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
+15
-15
@@ -66,7 +66,7 @@ export function AiSuggestionBadge({
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-1.5 px-2 py-1 rounded-md text-xs',
|
||||
'bg-purple-50 border border-purple-200',
|
||||
'dark:bg-purple-950/30 dark:border-purple-800',
|
||||
'',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -77,7 +77,7 @@ export function AiSuggestionBadge({
|
||||
type="text"
|
||||
value={editedValue}
|
||||
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 className="flex items-center gap-[0px] flex-shrink-0">
|
||||
@@ -107,7 +107,7 @@ export function AiSuggestionBadge({
|
||||
<Button
|
||||
size="sm"
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
@@ -201,14 +201,14 @@ export function AiSuggestionBadge({
|
||||
className={cn(
|
||||
'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',
|
||||
'dark:bg-purple-950/30 dark:border-purple-800 dark:hover:bg-purple-900/40',
|
||||
'',
|
||||
'transition-colors cursor-pointer',
|
||||
className
|
||||
)}
|
||||
title="Click to see AI suggestion"
|
||||
>
|
||||
<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'}
|
||||
</span>
|
||||
<ChevronDown className="h-3 w-3 text-purple-400" />
|
||||
@@ -222,7 +222,7 @@ export function AiSuggestionBadge({
|
||||
className={cn(
|
||||
'flex flex-col gap-2 p-3 rounded-md',
|
||||
'bg-purple-50 border border-purple-200',
|
||||
'dark:bg-purple-950/30 dark:border-purple-800',
|
||||
'',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -230,11 +230,11 @@ export function AiSuggestionBadge({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
</span>
|
||||
{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'})
|
||||
</span>
|
||||
)}
|
||||
@@ -261,7 +261,7 @@ export function AiSuggestionBadge({
|
||||
{issues.map((issue, index) => (
|
||||
<div
|
||||
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" />
|
||||
<span>{issue}</span>
|
||||
@@ -272,10 +272,10 @@ export function AiSuggestionBadge({
|
||||
|
||||
{/* Suggested description */}
|
||||
<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:
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
@@ -285,7 +285,7 @@ export function AiSuggestionBadge({
|
||||
<Button
|
||||
size="sm"
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
onAccept(suggestion);
|
||||
@@ -297,7 +297,7 @@ export function AiSuggestionBadge({
|
||||
<Button
|
||||
size="sm"
|
||||
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) => {
|
||||
e.stopPropagation();
|
||||
onDismiss();
|
||||
@@ -319,12 +319,12 @@ export function AiValidationLoading({ className }: { className?: string }) {
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1 rounded-md text-xs',
|
||||
'bg-purple-50 border border-purple-200',
|
||||
'dark:bg-purple-950/30 dark:border-purple-800',
|
||||
'',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<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...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
+5
-5
@@ -114,21 +114,21 @@ export const CopyDownBanner = memo(() => {
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
<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
|
||||
</span>
|
||||
{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>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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" />
|
||||
All
|
||||
@@ -149,7 +149,7 @@ export const CopyDownBanner = memo(() => {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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" />
|
||||
</Button>
|
||||
|
||||
+9
-9
@@ -62,7 +62,7 @@ export function SuggestionBadges({
|
||||
<div className={cn('flex flex-wrap items-center gap-1.5', className)}>
|
||||
{/* Label */}
|
||||
<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'
|
||||
)}>
|
||||
<Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} />
|
||||
@@ -71,7 +71,7 @@ export function SuggestionBadges({
|
||||
|
||||
{/* Loading state */}
|
||||
{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')} />
|
||||
{!compact && <span className="text-xs">Loading...</span>}
|
||||
</div>
|
||||
@@ -92,8 +92,8 @@ export function SuggestionBadges({
|
||||
'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',
|
||||
selected
|
||||
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-950 dark:text-green-400'
|
||||
: '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-green-300 bg-green-50 text-green-700'
|
||||
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
|
||||
)}
|
||||
title={suggestion.fullPath || suggestion.name}
|
||||
>
|
||||
@@ -141,8 +141,8 @@ export function InlineSuggestion({
|
||||
className={cn(
|
||||
'flex items-center justify-between px-2 py-1.5 cursor-pointer',
|
||||
isSelected
|
||||
? 'bg-green-50 dark:bg-green-950/30'
|
||||
: 'bg-purple-50/50 hover:bg-purple-100/50 dark:bg-purple-950/20 dark:hover:bg-purple-900/30'
|
||||
? 'bg-green-50'
|
||||
: 'bg-purple-50/50 hover:bg-purple-100/50'
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
@@ -154,7 +154,7 @@ export function InlineSuggestion({
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
{showScore && (
|
||||
<span className="text-xs text-purple-500 dark:text-purple-400">
|
||||
<span className="text-xs text-purple-500">
|
||||
{similarityPercent}%
|
||||
</span>
|
||||
)}
|
||||
@@ -178,12 +178,12 @@ interface SuggestionSectionHeaderProps {
|
||||
|
||||
export function SuggestionSectionHeader({ isLoading, count }: SuggestionSectionHeaderProps) {
|
||||
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" />
|
||||
<span>AI Suggested</span>
|
||||
{isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
{!isLoading && count !== undefined && (
|
||||
<span className="text-purple-400 dark:text-purple-500">({count})</span>
|
||||
<span className="text-purple-400">({count})</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-5
@@ -871,8 +871,8 @@ const CellWrapper = memo(({
|
||||
const cellHighlightClass = cn(
|
||||
'relative w-full group',
|
||||
isCopyDownSource && 'ring-2 ring-blue-500 ring-inset rounded',
|
||||
isInCopyDownRange && 'bg-blue-100 dark:bg-blue-900/30',
|
||||
isCopyDownTarget && !isInCopyDownRange && 'hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer'
|
||||
isInCopyDownRange && 'bg-blue-100',
|
||||
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
|
||||
@@ -933,7 +933,7 @@ const CellWrapper = memo(({
|
||||
className={cn(
|
||||
'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',
|
||||
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400',
|
||||
'',
|
||||
'shadow-sm',
|
||||
// Position further left if there are errors to avoid overlap
|
||||
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
|
||||
'shadow-[inset_0_-1px_0_0_hsl(var(--border))]',
|
||||
hasErrors && 'bg-destructive/5',
|
||||
isSelected && 'bg-blue-100 dark:bg-blue-900/40'
|
||||
isSelected && 'bg-blue-100'
|
||||
)}
|
||||
style={{
|
||||
height: ROW_HEIGHT,
|
||||
@@ -1534,7 +1534,7 @@ const VirtualRow = memo(({
|
||||
// Selection (blue) takes priority over errors (red)
|
||||
shouldBeSticky && (
|
||||
isSelected
|
||||
? "lg:[background:#dbeafe] lg:dark:[background:hsl(221_83%_25%/0.4)]"
|
||||
? "lg:[background:#dbeafe] lg:(221_83%_25%/0.4)]"
|
||||
: hasErrors
|
||||
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
|
||||
: "lg:[background:hsl(var(--background))]"
|
||||
|
||||
+5
-5
@@ -271,7 +271,7 @@ const MultiSelectCellComponent = ({
|
||||
{/* Selected items section - floats to top of dropdown */}
|
||||
{selectedValues.length > 0 && (
|
||||
<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" />
|
||||
<span>Selected ({selectedValues.length})</span>
|
||||
</div>
|
||||
@@ -286,7 +286,7 @@ const MultiSelectCellComponent = ({
|
||||
key={`selected-${selectedVal}`}
|
||||
value={`selected-${label}`}
|
||||
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" />
|
||||
{field.key === 'colors' && hexColor && (
|
||||
@@ -308,7 +308,7 @@ const MultiSelectCellComponent = ({
|
||||
{/* AI Suggestions section - shown below selected items */}
|
||||
{supportsSuggestions && (fieldSuggestions.length > 0 || suggestions.isLoading) && (
|
||||
<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" />
|
||||
<span>Suggested</span>
|
||||
{suggestions.isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
|
||||
@@ -328,7 +328,7 @@ const MultiSelectCellComponent = ({
|
||||
key={`suggestion-${suggestion.id}`}
|
||||
value={`suggestion-${suggestion.name}`}
|
||||
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">
|
||||
<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)}
|
||||
</span>
|
||||
</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}%
|
||||
</span>
|
||||
</CommandItem>
|
||||
|
||||
+3
-3
@@ -313,7 +313,7 @@ const MultilineInputComponent = ({
|
||||
'overflow-hidden leading-tight h-[65px] top-0',
|
||||
'border',
|
||||
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'
|
||||
)}
|
||||
>
|
||||
@@ -335,7 +335,7 @@ const MultilineInputComponent = ({
|
||||
setEditValue(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"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
@@ -344,7 +344,7 @@ const MultilineInputComponent = ({
|
||||
)}
|
||||
{/* AI validating indicator */}
|
||||
{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" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
+8
-8
@@ -67,7 +67,7 @@ const DiffDisplay = ({ original, corrected }: { original: unknown; corrected: un
|
||||
return (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@@ -77,7 +77,7 @@ const DiffDisplay = ({ original, corrected }: { original: unknown; corrected: un
|
||||
return (
|
||||
<span
|
||||
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}
|
||||
</span>
|
||||
@@ -153,22 +153,22 @@ const SummaryDisplay = ({
|
||||
<div className="space-y-2">
|
||||
{/* 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">
|
||||
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800 dark:text-blue-200">{summary}</div>
|
||||
<Info className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-blue-800">{summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warnings */}
|
||||
{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">
|
||||
<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">
|
||||
{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}
|
||||
</div>
|
||||
))}
|
||||
|
||||
+3
-3
@@ -205,7 +205,7 @@ export function SanityCheckDialog({
|
||||
{Object.entries(groups).map(([key, group]) => (
|
||||
<div
|
||||
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" />
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -234,12 +234,12 @@ export function SanityCheckDialog({
|
||||
</Button>
|
||||
)}
|
||||
{idx < group.productIndices.length - 1 && (
|
||||
<span className="text-gray-400 mr-1">,</span>
|
||||
<span className="text-muted-foreground/70 mr-1">,</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">{group.issue}</p>
|
||||
<p className="text-sm text-muted-foreground">{group.issue}</p>
|
||||
{group.suggestion && (
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{group.suggestion}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
|
||||
<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">
|
||||
{/* 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">
|
||||
{isLoadingProduct ? (
|
||||
<Skeleton className="h-16 w-16 rounded-lg" />
|
||||
|
||||
@@ -198,15 +198,15 @@ export function ProductTable({
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="border rounded-md relative">
|
||||
<div className="border rounded-xl bg-card relative overflow-hidden">
|
||||
{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" />
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
<SortableContext
|
||||
items={orderedVisibleColumns}
|
||||
|
||||
@@ -86,7 +86,7 @@ export default function FilterControls({
|
||||
placeholder="Search orders..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="max-w-xs"
|
||||
className="max-w-xs rounded-full px-3.5"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -106,11 +106,11 @@ export default function PipelineCard() {
|
||||
</div>
|
||||
</div>
|
||||
{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" />
|
||||
<div>
|
||||
<p className="text-xs text-red-600 dark:text-red-400">Overdue</p>
|
||||
<p className="text-sm font-bold text-red-600 dark:text-red-400">
|
||||
<p className="text-xs text-red-600">Overdue</p>
|
||||
<p className="text-sm font-bold text-red-600">
|
||||
{data.overdue.count} POs ({formatCurrency(data.overdue.value)})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function PurchaseOrderAccordion({
|
||||
// Clone the TableRow (children) and add the onClick handler and className
|
||||
const enhancedRow = React.cloneElement(children as React.ReactElement, {
|
||||
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"
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ export default function PurchaseOrderAccordion({
|
||||
}
|
||||
|
||||
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">
|
||||
<TableHeader className="bg-white sticky top-0 z-10">
|
||||
<TableRow>
|
||||
@@ -151,7 +151,7 @@ export default function PurchaseOrderAccordion({
|
||||
))
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<TableRow key={item.id} className="hover:bg-gray-100">
|
||||
<TableRow key={item.id} className="hover:bg-muted">
|
||||
<TableCell className="">
|
||||
<a
|
||||
href={`https://backend.acherryontop.com/product/${item.pid}`}
|
||||
@@ -228,7 +228,7 @@ export default function PurchaseOrderAccordion({
|
||||
{isOpen && (
|
||||
<TableRow 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">
|
||||
{purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"}
|
||||
</div>
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function PurchaseOrdersTable({
|
||||
return (
|
||||
<Badge
|
||||
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"}
|
||||
</Badge>
|
||||
@@ -323,7 +323,7 @@ export default function PurchaseOrdersTable({
|
||||
) : purchaseOrders.length > 0 ? (
|
||||
purchaseOrders.map((po) => {
|
||||
// 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") {
|
||||
rowClassName = "border-l-4 border-l-green-500";
|
||||
|
||||
@@ -307,11 +307,11 @@ function Filters({
|
||||
|
||||
function SuccessBadge({ success }: { success: boolean }) {
|
||||
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
|
||||
</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
|
||||
</Badge>
|
||||
);
|
||||
@@ -342,7 +342,7 @@ function EditorTable({ entries, onView }: { entries: AuditEntry[]; onView: (id:
|
||||
href={`https://backend.acherryontop.com/product/${e.pid}`}
|
||||
target="_blank"
|
||||
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}
|
||||
</a>
|
||||
@@ -434,7 +434,7 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
|
||||
href={`https://backend.acherryontop.com/product/${detail.pid}`}
|
||||
target="_blank"
|
||||
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}
|
||||
</a>
|
||||
@@ -478,7 +478,7 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
|
||||
{detail.error_message && (
|
||||
<div>
|
||||
<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}
|
||||
</p>
|
||||
</div>
|
||||
@@ -576,11 +576,11 @@ function JsonValue({ value, depth }: { value: unknown; depth: number }) {
|
||||
}
|
||||
|
||||
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") {
|
||||
return <span className="text-blue-600 dark:text-blue-400">{value}</span>;
|
||||
return <span className="text-blue-600">{value}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
@@ -605,7 +605,7 @@ function JsonString({ value }: { value: string }) {
|
||||
|
||||
if (value.length <= STRING_TRUNCATE_LIMIT || expanded) {
|
||||
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}
|
||||
{expanded && value.length > STRING_TRUNCATE_LIMIT && (
|
||||
<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 (
|
||||
<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)}
|
||||
<span className="text-muted-foreground">... </span>
|
||||
<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) => (
|
||||
<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>
|
||||
<JsonValue value={v} depth={depth + 1} />
|
||||
{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"
|
||||
>
|
||||
{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>
|
||||
{open && (
|
||||
<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
|
||||
return (
|
||||
<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>
|
||||
<JsonValue value={value} depth={depth + 1} />
|
||||
</div>
|
||||
|
||||
@@ -401,12 +401,12 @@ export function DataManagement() {
|
||||
if (data.completed_steps && Array.isArray(data.completed_steps)) {
|
||||
return (
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="text-sm font-semibold text-gray-700">Completed Steps:</div>
|
||||
<div className="space-y-1 bg-gray-50 p-2 rounded">
|
||||
<div className="text-sm font-semibold text-foreground">Completed Steps:</div>
|
||||
<div className="space-y-1 bg-surface-subtle p-2 rounded">
|
||||
{data.completed_steps.map((step: any, idx: number) => (
|
||||
<div key={idx} className="flex justify-between text-sm">
|
||||
<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>
|
||||
{step.rowsAffected !== undefined && (
|
||||
<span>{formatNumber(step.rowsAffected)} rows</span>
|
||||
@@ -434,15 +434,15 @@ export function DataManagement() {
|
||||
if (data.details) {
|
||||
return (
|
||||
<div className="space-y-2 mt-2">
|
||||
<div className="text-sm font-semibold text-gray-700">Import Details:</div>
|
||||
<div className="space-y-2 bg-gray-50 p-2 rounded">
|
||||
<div className="text-sm font-semibold text-foreground">Import Details:</div>
|
||||
<div className="space-y-2 bg-surface-subtle p-2 rounded">
|
||||
{Object.entries(data.details).map(([table, stats]: [string, any]) => (
|
||||
<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="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
|
||||
{Object.entries(stats).map(([key, value]) => (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
@@ -453,11 +453,11 @@ export function DataManagement() {
|
||||
{/* Show step timings if present */}
|
||||
{data.step_timings && (
|
||||
<div className="mt-2">
|
||||
<div className="text-sm font-semibold text-gray-700">Step Timings:</div>
|
||||
<div className="space-y-1 bg-gray-50 p-2 rounded text-sm">
|
||||
<div className="text-sm font-semibold text-foreground">Step Timings:</div>
|
||||
<div className="space-y-1 bg-surface-subtle p-2 rounded text-sm">
|
||||
{Object.entries(data.step_timings).map(([step, duration]) => (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
@@ -480,11 +480,11 @@ export function DataManagement() {
|
||||
);
|
||||
|
||||
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]) => (
|
||||
<div key={key} className="flex">
|
||||
<span
|
||||
className="text-gray-600 font-bold shrink-0"
|
||||
className="text-muted-foreground font-bold shrink-0"
|
||||
style={{ width: `${maxKeyLength + 2}ch` }}
|
||||
>
|
||||
{key}:
|
||||
@@ -920,7 +920,7 @@ export function DataManagement() {
|
||||
{table.error ? (
|
||||
<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>
|
||||
))}
|
||||
@@ -1104,7 +1104,7 @@ export function DataManagement() {
|
||||
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="text-sm text-gray-600">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatStatusTime(table.last_sync_timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1146,7 +1146,7 @@ export function DataManagement() {
|
||||
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="text-sm text-gray-600">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatStatusTime(module.last_calculation_timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1197,7 +1197,7 @@ export function DataManagement() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-[170px]">
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(record.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1230,7 +1230,7 @@ export function DataManagement() {
|
||||
<AccordionContent className="px-4 pb-2">
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">End Time:</span>
|
||||
<span className="text-muted-foreground">End Time:</span>
|
||||
<span>
|
||||
{record.end_time
|
||||
? formatDate(record.end_time)
|
||||
@@ -1239,28 +1239,28 @@ export function DataManagement() {
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{record.records_deleted !== undefined && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{record.records_skipped !== undefined && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{record.total_processed !== undefined && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -1319,7 +1319,7 @@ export function DataManagement() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-[170px]">
|
||||
<span className="text-sm text-gray-600">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDate(record.start_time)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1352,7 +1352,7 @@ export function DataManagement() {
|
||||
<AccordionContent className="px-4 pb-2">
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-600">End Time:</span>
|
||||
<span className="text-muted-foreground">End Time:</span>
|
||||
<span>
|
||||
{record.end_time
|
||||
? formatDate(record.end_time)
|
||||
@@ -1360,15 +1360,15 @@ export function DataManagement() {
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
{record.error_message && (
|
||||
|
||||
@@ -89,21 +89,6 @@ export function GlobalSettings() {
|
||||
</SelectContent>
|
||||
</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')) {
|
||||
// Percentage inputs
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input";
|
||||
import { toast } from "sonner";
|
||||
import config from '../../config';
|
||||
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 { Switch } from "@/components/ui/switch";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
@@ -25,7 +24,6 @@ interface ProductSetting {
|
||||
lead_time_days: number | null;
|
||||
days_of_stock: number | null;
|
||||
safety_stock: number;
|
||||
forecast_method: string | null;
|
||||
exclude_from_forecast: boolean;
|
||||
updated_at: string;
|
||||
product_name?: string; // Added for display purposes
|
||||
@@ -86,7 +84,6 @@ export function ProductSettings() {
|
||||
lead_time_days: setting.lead_time_days,
|
||||
days_of_stock: setting.days_of_stock,
|
||||
safety_stock: setting.safety_stock,
|
||||
forecast_method: setting.forecast_method,
|
||||
exclude_from_forecast: setting.exclude_from_forecast
|
||||
})
|
||||
});
|
||||
@@ -180,7 +177,7 @@ export function ProductSettings() {
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search products by ID or name..."
|
||||
className="pl-8"
|
||||
className="pl-8 rounded-full"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
@@ -195,7 +192,6 @@ export function ProductSettings() {
|
||||
<TableHead>Lead Time (days)</TableHead>
|
||||
<TableHead>Days of Stock</TableHead>
|
||||
<TableHead>Safety Stock</TableHead>
|
||||
<TableHead>Forecast Method</TableHead>
|
||||
<TableHead>Exclude</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
@@ -231,21 +227,6 @@ export function ProductSettings() {
|
||||
onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)}
|
||||
/>
|
||||
</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>
|
||||
<Switch
|
||||
checked={setting.exclude_from_forecast}
|
||||
|
||||
@@ -543,7 +543,7 @@ export function PromptManagement() {
|
||||
placeholder="Search prompts..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-sm"
|
||||
className="max-w-sm rounded-full px-3.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -568,7 +568,7 @@ export function PromptManagement() {
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
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) => (
|
||||
<TableCell key={cell.id} className="pl-3 whitespace-nowrap">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
|
||||
@@ -593,7 +593,7 @@ export function ReusableImageManagement() {
|
||||
placeholder="Search images..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-sm"
|
||||
className="max-w-sm rounded-full px-3.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -621,7 +621,7 @@ export function ReusableImageManagement() {
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
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) => (
|
||||
<TableCell key={cell.id} className="pl-6">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
|
||||
@@ -276,7 +276,7 @@ export function TemplateManagement() {
|
||||
placeholder="Search templates..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="max-w-sm"
|
||||
className="max-w-sm rounded-full px-3.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -304,7 +304,7 @@ export function TemplateManagement() {
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
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) => (
|
||||
<TableCell key={cell.id} className="pl-6">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function UserList({ users, onEdit, onDelete }: UserListProps) {
|
||||
{user.is_active ? (
|
||||
<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>
|
||||
|
||||
@@ -172,7 +172,7 @@ export function VendorSettings() {
|
||||
<Input
|
||||
type="search"
|
||||
placeholder="Search vendors..."
|
||||
className="pl-8"
|
||||
className="pl-8 rounded-full"
|
||||
value={searchInputValue}
|
||||
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";
|
||||
@@ -738,7 +738,7 @@ export function SearchProductTemplateDialog({ isOpen, onClose, onTemplateCreated
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-background z-10 border-b">
|
||||
<TableHeader className="sticky top-0 bg-card z-10 border-b">
|
||||
<TableRow>
|
||||
<SortableTableHead field="title">Name</SortableTableHead>
|
||||
{searchParams.company === 'all' && (
|
||||
|
||||
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,7 +10,7 @@ const alertVariants = cva(
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
"border-destructive/50 text-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-full text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
@@ -14,7 +14,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-input bg-card shadow-sm hover:bg-surface-subtle hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
@@ -22,8 +22,8 @@ const buttonVariants = cva(
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
sm: "h-8 px-3 text-xs",
|
||||
lg: "h-10 px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-[14px] border bg-card text-card-foreground shadow-[0_1px_2px_rgba(43,41,37,.04)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-base font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -43,7 +43,7 @@ const DrawerContent = React.forwardRef<
|
||||
<DrawerPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed z-50 gap-4 bg-background",
|
||||
"fixed z-50 gap-4 bg-card",
|
||||
{
|
||||
"inset-x-0 bottom-0 mt-24 rounded-t-[10px] border-t": side === "bottom",
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-xl": side === "right",
|
||||
|
||||
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-full border border-input bg-card px-3.5 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"fixed z-50 gap-4 bg-card p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
|
||||
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-9 items-center justify-center rounded-full bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-full px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"inline-flex items-center justify-center gap-2 rounded-full text-sm font-medium transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
|
||||
@@ -62,8 +62,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.error('Auth check failed:', response.status, errorData);
|
||||
// Any failed /me response means the session is invalid — logout
|
||||
logout();
|
||||
// Only a genuine auth rejection ends the session. Server errors and
|
||||
// rate limits (500/429/etc.) are transient — keep the session.
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
logout();
|
||||
} else {
|
||||
setError(errorData.error || `Auth check failed (${response.status})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
/**
|
||||
* A Date that stays current: re-renders every `intervalMs` (default 1 min)
|
||||
* and immediately when the tab becomes visible again — so greetings and
|
||||
* date lines roll over even if the page sat open (or backgrounded on a
|
||||
* phone) across a morning/afternoon/midnight boundary.
|
||||
*/
|
||||
export function useNow(intervalMs = 60_000): Date {
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => setNow(new Date());
|
||||
const id = setInterval(update, intervalMs);
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === "visible") update();
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
};
|
||||
}, [intervalMs]);
|
||||
|
||||
return now;
|
||||
}
|
||||
+46
-42
@@ -4,64 +4,68 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
/* Sorbet Studio palette (2026-07 app-wide restyle).
|
||||
Semantic slots re-pointed at the dashboard's Studio hues so the whole
|
||||
app shares one warm system: ground #f8f7f5, ink #2b2925, hairline
|
||||
#e7e5e1, wells #f0eeea, coral #e06a4e as the working accent. */
|
||||
--background: 40 17.6% 96.7%; /* #f8f7f5 - Studio ground */
|
||||
--foreground: 40 7.5% 15.7%; /* #2b2925 - Studio ink */
|
||||
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 40 7.5% 15.7%;
|
||||
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--popover-foreground: 40 7.5% 15.7%;
|
||||
|
||||
--primary: 222.2 47.4% 11.2%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--primary: 40 7.5% 15.7%; /* ink - actions stay neutral; coral is accent-only */
|
||||
--primary-foreground: 40 23.1% 97.5%; /* #faf9f7 */
|
||||
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--secondary: 40 16.7% 92.9%; /* #f0eeea - Studio well */
|
||||
--secondary-foreground: 40 7.5% 15.7%;
|
||||
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--muted: 40 16.7% 92.9%; /* #f0eeea */
|
||||
--muted-foreground: 40 5.1% 46.3%; /* #7c7870 - Studio muted */
|
||||
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--accent: 40 16.7% 92.9%;
|
||||
--accent-foreground: 40 7.5% 15.7%;
|
||||
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--destructive: 8.8 47.9% 47.5%; /* #b3503f - Studio down */
|
||||
--destructive-foreground: 40 23.1% 97.5%;
|
||||
|
||||
--info: 217.2 91.2% 59.8%;
|
||||
--info-foreground: 222.2 47.4% 11.2%;
|
||||
--info: 205.4 46.8% 46.5%; /* #3f7fae - slate blue (chart-secondary) */
|
||||
--info-foreground: 40 7.5% 15.7%;
|
||||
|
||||
--success: 142.1 76.2% 36.3%;
|
||||
--success-foreground: 222.2 47.4% 11.2%;
|
||||
--success: 149.0 55.4% 30.8%; /* #237a4d - Studio up */
|
||||
--success-foreground: 40 7.5% 15.7%;
|
||||
|
||||
--warning: 45.4 93.4% 47.5%;
|
||||
--warning-foreground: 222.2 47.4% 11.2%;
|
||||
--warning: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
|
||||
--warning-foreground: 40 7.5% 15.7%;
|
||||
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 222.2 84% 4.9%;
|
||||
--border: 40 11.1% 89.4%; /* #e7e5e1 - Studio hairline */
|
||||
--input: 40 11.1% 89.4%;
|
||||
--ring: 11.5 70.2% 59.2%; /* #e06a4e - coral focus */
|
||||
|
||||
--radius: 0.5rem;
|
||||
/* Extra Studio surface: subtle wells inside white cards (#faf9f7) */
|
||||
--surface-subtle: 40 23.1% 97.5%;
|
||||
|
||||
--sidebar-background: 0 0% 98%;
|
||||
/* Studio working accent (#e06a4e) - use sparingly: focus rings, one
|
||||
accent per section, active highlights. Not for buttons. */
|
||||
--coral: 11.5 70.2% 59.2%;
|
||||
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--radius: 0.625rem;
|
||||
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--sidebar-background: 36 17.2% 94.3%; /* #f3f1ee - a step deeper than ground */
|
||||
--sidebar-foreground: 33.3 5.5% 32.4%; /* #57534e */
|
||||
--sidebar-primary: 40 7.5% 15.7%;
|
||||
--sidebar-primary-foreground: 40 23.1% 97.5%;
|
||||
--sidebar-accent: 37.5 16.0% 90.2%; /* #eae7e2 - active/hover well */
|
||||
--sidebar-accent-foreground: 40 7.5% 15.7%;
|
||||
--sidebar-border: 40 11.1% 89.4%;
|
||||
--sidebar-ring: 11.5 70.2% 59.2%;
|
||||
|
||||
/* Dashboard card with glass effect */
|
||||
--card-glass: 0 0% 100%;
|
||||
--card-glass-foreground: 222.2 84% 4.9%;
|
||||
--card-glass-foreground: 40 7.5% 15.7%;
|
||||
|
||||
/* Semantic chart colors - Sorbet Studio palette (must match METRIC_COLORS) */
|
||||
--chart-revenue: 11.5 70.2% 59.2%; /* #e06a4e - Studio coral */
|
||||
@@ -178,13 +182,13 @@
|
||||
.dashboard-scroll {
|
||||
@apply overflow-y-auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgb(229 231 235) transparent;
|
||||
scrollbar-color: #e7e5e1 transparent;
|
||||
}
|
||||
.dark .dashboard-scroll {
|
||||
scrollbar-color: rgb(55 65 81) transparent;
|
||||
}
|
||||
.dashboard-scroll:hover {
|
||||
scrollbar-color: rgb(209 213 219) transparent;
|
||||
scrollbar-color: #d8d4cd transparent;
|
||||
}
|
||||
.dark .dashboard-scroll:hover {
|
||||
scrollbar-color: rgb(75 85 99) transparent;
|
||||
@@ -213,11 +217,11 @@
|
||||
|
||||
/* Full dashboard controls intentionally stay denser than the rest of the app. */
|
||||
.dashboard-page [role="tablist"] {
|
||||
@apply h-8 rounded-md bg-muted/60 p-0.5;
|
||||
@apply h-8 rounded-full bg-muted/60 p-0.5;
|
||||
}
|
||||
|
||||
.dashboard-page [role="tab"] {
|
||||
@apply h-7 rounded px-2.5 text-xs;
|
||||
@apply h-7 rounded-full px-2.5 text-xs;
|
||||
}
|
||||
|
||||
.dashboard-page [role="combobox"] {
|
||||
|
||||
@@ -18,17 +18,17 @@
|
||||
*/
|
||||
export const CARD_STYLES = {
|
||||
/** Base card appearance (Sorbet Studio: white panel, warm border, 14px radius) */
|
||||
base: "bg-white border border-[#e7e5e1] shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
|
||||
base: "bg-card border border-border shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
|
||||
/** Elevated card for primary/featured sections (Sales, Financial) */
|
||||
elevated: "bg-white border border-[#e7e5e1] shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
|
||||
elevated: "bg-card border border-border shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
|
||||
/** Subtle card for secondary/supporting content */
|
||||
subtle: "bg-[#faf9f7] border border-[#efede9] rounded-[12px]",
|
||||
subtle: "bg-surface-subtle border border-muted rounded-[12px]",
|
||||
/** Accent card with subtle ring highlight */
|
||||
accent: "bg-white border border-[#e06a4e]/25 shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
|
||||
accent: "bg-card border border-coral/25 shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
|
||||
/** Card with subtle hover effect */
|
||||
interactive: "bg-white border border-[#e7e5e1] shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px] transition-colors duration-150 hover:border-[#d8d4cd] hover:bg-[#faf9f7]",
|
||||
interactive: "bg-card border border-border shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px] transition-colors duration-150 hover:border-[#d8d4cd] hover:bg-surface-subtle",
|
||||
/** Solid card without glass effect (use sparingly) */
|
||||
solid: "bg-white border border-[#e7e5e1] shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
|
||||
solid: "bg-card border border-border shadow-[0_1px_2px_rgba(43,41,37,.04)] rounded-[14px]",
|
||||
/** Card header layout */
|
||||
header: "flex flex-row items-center justify-between p-3.5 pb-0",
|
||||
/** Compact header for stat cards */
|
||||
@@ -372,11 +372,11 @@ export const STAT_ICON_STYLES = {
|
||||
* Table styling tokens
|
||||
*/
|
||||
export const TABLE_STYLES = {
|
||||
container: "rounded-xl border border-[#efede9] overflow-hidden",
|
||||
header: "bg-[#faf9f7]",
|
||||
headerCell: "text-[10.5px] font-semibold text-[#a09b92] uppercase tracking-wide",
|
||||
row: "border-b border-[#f0eeea] last:border-0",
|
||||
rowHover: "hover:bg-[#faf9f7] transition-colors",
|
||||
container: "rounded-xl border border-muted overflow-hidden",
|
||||
header: "bg-surface-subtle",
|
||||
headerCell: "text-[10.5px] font-semibold text-muted-foreground/75 uppercase tracking-wide",
|
||||
row: "border-b border-muted last:border-0",
|
||||
rowHover: "hover:bg-surface-subtle transition-colors",
|
||||
cell: "text-xs",
|
||||
cellNumeric: "text-sm tabular-nums text-right",
|
||||
} as const;
|
||||
|
||||
@@ -807,7 +807,7 @@ export function BlackFridayDashboard() {
|
||||
<DollarSign className="h-3.5 w-3.5" />
|
||||
Revenue
|
||||
</div>
|
||||
<div className="text-3xl font-bold tracking-tight tabular-nums text-emerald-600 dark:text-emerald-400">
|
||||
<div className="text-3xl font-bold tracking-tight tabular-nums text-emerald-600">
|
||||
{formatCurrency(currentYearData.totals.revenue, 0)}
|
||||
</div>
|
||||
{yoy !== null && (
|
||||
@@ -838,7 +838,7 @@ export function BlackFridayDashboard() {
|
||||
<TrendingUp className="h-3.5 w-3.5" />
|
||||
Profit
|
||||
</div>
|
||||
<div className="text-2xl font-bold tabular-nums text-emerald-600 dark:text-emerald-400">
|
||||
<div className="text-2xl font-bold tabular-nums text-emerald-600">
|
||||
{formatCurrency(currentYearData.totals.profit, 0)}
|
||||
</div>
|
||||
{lastYearData && (
|
||||
@@ -913,7 +913,7 @@ export function BlackFridayDashboard() {
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<span className={cn(
|
||||
"text-xs font-semibold",
|
||||
isToday ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground"
|
||||
isToday ? "text-emerald-600" : "text-muted-foreground"
|
||||
)}>
|
||||
{day.label}
|
||||
</span>
|
||||
@@ -1306,7 +1306,7 @@ export function BlackFridayDashboard() {
|
||||
>
|
||||
<TableCell className={cn(
|
||||
"text-xs h-9 pl-4",
|
||||
isCurrentYear ? "font-bold text-emerald-600 dark:text-emerald-400" : "font-semibold"
|
||||
isCurrentYear ? "font-bold text-emerald-600" : "font-semibold"
|
||||
)}>
|
||||
{year}
|
||||
</TableCell>
|
||||
@@ -1319,7 +1319,7 @@ export function BlackFridayDashboard() {
|
||||
<TableCell className="text-xs h-9 text-right tabular-nums text-muted-foreground">
|
||||
{formatCurrency(entry.totals.avgOrderValue, 0)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs h-9 text-right tabular-nums font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<TableCell className="text-xs h-9 text-right tabular-nums font-medium text-emerald-600">
|
||||
{formatCurrency(entry.totals.profit, 0)}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs h-9 text-right pr-4">
|
||||
|
||||
@@ -529,7 +529,7 @@ function BrandsPanel() {
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -728,7 +728,7 @@ function VendorsPanel() {
|
||||
/>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -272,7 +272,7 @@ const TypeBadge = ({
|
||||
}) => {
|
||||
const typeLabel = TYPE_LABELS[type] || `Type ${type}`;
|
||||
const colorClass =
|
||||
TYPE_COLORS[type] || "bg-gray-100 text-gray-800 hover:bg-gray-200";
|
||||
TYPE_COLORS[type] || "bg-muted text-foreground hover:bg-muted";
|
||||
|
||||
return (
|
||||
<Badge
|
||||
@@ -1284,7 +1284,7 @@ export function Categories() {
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Navigate } from "react-router-dom";
|
||||
import PinProtection from "@/components/dashboard/PinProtection";
|
||||
import StudioMobile from "@/components/dashboard/mobile/StudioMobile";
|
||||
import PageLoading from "@/components/ui/page-loading";
|
||||
import { apiFetch } from "@/utils/api";
|
||||
|
||||
// Pin Protected Layout
|
||||
const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
const [isPinVerified, setIsPinVerified] = useState(() => {
|
||||
return sessionStorage.getItem("pinVerified") === "true";
|
||||
});
|
||||
|
||||
const handlePinSuccess = () => {
|
||||
setIsPinVerified(true);
|
||||
sessionStorage.setItem("pinVerified", "true");
|
||||
};
|
||||
|
||||
if (!isPinVerified) {
|
||||
return <PinProtection onSuccess={handlePinSuccess} />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
// Same three-way gate as /small: office IP gets PIN, authenticated users skip
|
||||
// PIN, everyone else is bounced to login. Identity comes from
|
||||
// /api/dashboard/whoami (behind Caddy's office-IP allowlist for the kiosk case).
|
||||
type Identity = "probing" | "kiosk" | "authenticated" | "anonymous";
|
||||
|
||||
const AccessGate = ({ children }: { children: React.ReactNode }) => {
|
||||
const [identity, setIdentity] = useState<Identity>("probing");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch("/api/dashboard/whoami");
|
||||
if (cancelled) return;
|
||||
if (res.status === 401) {
|
||||
setIdentity("anonymous");
|
||||
return;
|
||||
}
|
||||
const body = await res.json();
|
||||
setIdentity(body.is_kiosk ? "kiosk" : "authenticated");
|
||||
} catch {
|
||||
if (!cancelled) setIdentity("anonymous");
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (identity === "probing") return <PageLoading />;
|
||||
if (identity === "anonymous") return <Navigate to="/login?redirect=/mobile" replace />;
|
||||
if (identity === "kiosk") return <PinProtectedLayout>{children}</PinProtectedLayout>;
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export function MobileDashboard() {
|
||||
return (
|
||||
<AccessGate>
|
||||
<StudioMobile />
|
||||
</AccessGate>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileDashboard;
|
||||
@@ -671,7 +671,7 @@ export default function ProductEditor() {
|
||||
</div>
|
||||
)}
|
||||
{queryStatus.unsupported.length > 0 && (
|
||||
<div className="text-amber-600 dark:text-amber-400">
|
||||
<div className="text-amber-600">
|
||||
{queryStatus.unsupported.length} filter type{queryStatus.unsupported.length !== 1 ? "s" : ""} not supported ({queryStatus.unsupported.join(", ")}). Results may be broader than expected.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -693,7 +693,7 @@ export function ProductLines() {
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="rounded-md border">
|
||||
<div className="rounded-xl border bg-card overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
||||
@@ -499,7 +499,7 @@ export function Products() {
|
||||
placeholder='Search products by name... (press "/")'
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 pr-8 h-9"
|
||||
className="pl-9 pr-8 h-9 rounded-full"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
|
||||
@@ -51,6 +51,8 @@ export default {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
'surface-subtle': 'hsl(var(--surface-subtle))',
|
||||
coral: 'hsl(var(--coral))',
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user