3 Commits

90 changed files with 1999 additions and 587 deletions
+16 -8
View File
@@ -12,13 +12,20 @@ export function createAuthRoutes({ pool }) {
// shared/auth/middleware.js and is used by downstream services. Auth-server's surface is // shared/auth/middleware.js and is used by downstream services. Auth-server's surface is
// small enough that a local copy is fine; the security boundary is the JWT verify step. // small enough that a local copy is fine; the security boundary is the JWT verify step.
async function authenticate(req, res, next) { async function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
// DB failures are 500, not 401 — the frontend ends the session on 401,
// and a transient outage must not log users out.
try { try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const result = await pool.query( const result = await pool.query(
'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1', 'SELECT id, username, email, is_admin, rocket_chat_user_id FROM users WHERE id = $1',
[decoded.userId] [decoded.userId]
@@ -29,7 +36,8 @@ export function createAuthRoutes({ pool }) {
req.user = result.rows[0]; req.user = result.rows[0];
next(); next();
} catch (error) { } catch (error) {
res.status(401).json({ error: 'Invalid token' }); console.error('Auth DB error:', error);
res.status(500).json({ error: 'Server error' });
} }
} }
@@ -58,7 +66,7 @@ export function createAuthRoutes({ pool }) {
const token = jwt.sign( const token = jwt.sign(
{ userId: user.id, username: user.username }, { userId: user.id, username: user.username },
process.env.JWT_SECRET, process.env.JWT_SECRET,
{ expiresIn: '8h' } { expiresIn: '7d' }
); );
const permissions = await getUserPermissions(user.id); const permissions = await getUserPermissions(user.id);
res.json({ res.json({
+5
View File
@@ -39,6 +39,11 @@ logger.info({
const app = express(); const app = express();
const port = Number(process.env.AUTH_PORT) || 3011; const port = Number(process.env.AUTH_PORT) || 3011;
// Behind Caddy on localhost: without this, req.ip is ::1 for every request and
// the rate limiters put ALL users in one shared bucket (10 logins/15min for
// the whole office). Same setting as inventory-server's server.js.
app.set('trust proxy', 'loopback');
const pool = new Pool({ const pool = new Pool({
host: process.env.DB_HOST, host: process.env.DB_HOST,
user: process.env.DB_USER, user: process.env.DB_USER,
@@ -38,26 +38,54 @@ const getImageUrls = (pid, iid = 1) => {
}; };
}; };
// Main stats endpoint - replaces /api/klaviyo/events/stats // The DB sits across an SSH tunnel (~190ms per round trip; the queries
router.get('/stats', async (req, res) => { // themselves are ~5ms on the server), so /stats response time is dominated by
const startTime = Date.now(); // round-trip count, not query cost. Two mitigations:
console.log(`[STATS] Starting request for timeRange: ${req.query.timeRange}`); // 1. computeStats runs its queries over several pooled connections (mysql2
// serializes per connection), capped so concurrent refreshes can't starve
// Set a timeout for the entire operation // the 20-slot pool.
const timeoutPromise = new Promise((_, reject) => { // 2. Responses are cached per param-set and served stale-while-revalidate:
setTimeout(() => reject(new Error('Request timeout after 15 seconds')), 15000); // the dashboards poll every 30-60s, so they get the cached payload
}); // instantly while a single-flight background refresh keeps it current.
const STATS_QUERY_WIDTH = 4;
try { const STATS_CACHE_TTL = 60 * 1000; // younger than this: serve as-is
const mainOperation = async () => { const STATS_CACHE_MAX_STALE = 15 * 60 * 1000; // older than this: block on refresh
const { timeRange, startDate, endDate, excludeCherryBox } = req.query; const statsCache = new Map(); // cacheKey -> { data, timestamp }
const excludeCB = parseBoolParam(excludeCherryBox); const statsInflight = new Map(); // cacheKey -> Promise<response>
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
const { connection, release } = await getDbConnection();
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
try {
const withTimeout = (promise, ms, label) => {
let timer;
const timeout = new Promise((_, reject) => {
timer = setTimeout(() => reject(new Error(`${label} timeout after ${ms / 1000} seconds`)), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
};
// Drain [name, fn] tasks with up to `width` workers, each holding its own
// pooled connection, so wall time is ~ceil(tasks / width) round trips. If a
// task rejects, remaining queued tasks are abandoned and every worker still
// releases its connection (mirrors the old single-connection failure mode).
async function runStatsTasks(tasks, width) {
const results = {};
const queue = [...tasks];
const worker = async () => {
const { connection, release } = await getDbConnection();
try {
let task;
while ((task = queue.shift()) !== undefined) {
results[task[0]] = await task[1](connection);
}
} finally {
release();
}
};
await Promise.all(Array.from({ length: Math.min(width, tasks.length) }, worker));
return results;
}
async function computeStats(timeRange, startDate, endDate, excludeCB) {
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
const isSingleDay = ['today', 'yesterday'].includes(timeRange);
// Main order stats query (optionally excludes Cherry Box orders) // Main order stats query (optionally excludes Cherry Box orders)
// Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately // Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately
@@ -75,9 +103,6 @@ router.get('/stats', async (req, res) => {
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`; `;
const [mainStats] = await connection.execute(mainStatsQuery, params);
const stats = mainStats[0];
// Cancelled orders query - uses date_cancelled instead of date_placed // Cancelled orders query - uses date_cancelled instead of date_placed
// Shows orders cancelled during the selected period, regardless of when they were placed // Shows orders cancelled during the selected period, regardless of when they were placed
const cancelledQuery = ` const cancelledQuery = `
@@ -90,9 +115,6 @@ router.get('/stats', async (req, res) => {
AND ${whereClause.replace(/date_placed/g, 'date_cancelled')} AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
`; `;
const [cancelledResult] = await connection.execute(cancelledQuery, params);
const cancelledStats = cancelledResult[0] || { cancelledCount: 0, cancelledTotal: 0 };
// Refunds query (optionally excludes Cherry Box orders) // Refunds query (optionally excludes Cherry Box orders)
const refundsQuery = ` const refundsQuery = `
SELECT SELECT
@@ -103,8 +125,6 @@ router.get('/stats', async (req, res) => {
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')} WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
`; `;
const [refundStats] = await connection.execute(refundsQuery, params);
// Shipped orders query - uses date_shipped instead of date_placed // Shipped orders query - uses date_shipped instead of date_placed
// This counts orders that were SHIPPED during the selected period, regardless of when they were placed // This counts orders that were SHIPPED during the selected period, regardless of when they were placed
const shippedQuery = ` const shippedQuery = `
@@ -115,9 +135,6 @@ router.get('/stats', async (req, res) => {
AND ${whereClause.replace(/date_placed/g, 'date_shipped')} AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
`; `;
const [shippedResult] = await connection.execute(shippedQuery, params);
const shippedCount = parseInt(shippedResult[0]?.shippedCount || 0);
// Best revenue day query (optionally excludes Cherry Box orders) // Best revenue day query (optionally excludes Cherry Box orders)
const bestDayQuery = ` const bestDayQuery = `
SELECT SELECT
@@ -131,78 +148,38 @@ router.get('/stats', async (req, res) => {
LIMIT 1 LIMIT 1
`; `;
const [bestDayResult] = await connection.execute(bestDayQuery, params);
// Peak hour query - uses selected time range for the card value. // Peak hour query - uses selected time range for the card value.
// date_placed stores Central wall-clock; the office reads the axis in // date_placed stores Central wall-clock; the office reads the axis in
// Eastern, and ET = CT + 1h year-round, so shift before taking HOUR(). // Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
let peakHour = null; // Only run for single-day ranges (see tasks below).
if (['today', 'yesterday'].includes(timeRange)) { const peakHourQuery = `
const peakHourQuery = ` SELECT
SELECT HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
HOUR(ADDTIME(date_placed, '01:00:00')) as hour, COUNT(*) as count
COUNT(*) as count FROM _order
FROM _order WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00')) ORDER BY count DESC
ORDER BY count DESC LIMIT 1
LIMIT 1 `;
`;
const [peakHourResult] = await connection.execute(peakHourQuery, params);
if (peakHourResult.length > 0) {
const hour = parseInt(peakHourResult[0].hour);
peakHour = {
hour,
count: parseInt(peakHourResult[0].count),
displayHour: DateTime.fromObject({ hour }).toFormat('h a')
};
}
}
// Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days) // Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days)
// Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour] // Hourly counts in EASTERN display hours (column stores Central
let hourlyOrders = null; // wall-clock; ET = CT + 1h year-round). currentHour comes from the same
if (['today', 'yesterday'].includes(timeRange)) { // expression so the rolling window stays aligned.
// Hourly counts in EASTERN display hours (column stores Central const hourlyQuery = `
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same SELECT
// expression so the rolling window stays aligned. HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
const hourlyQuery = ` COUNT(*) as count,
SELECT HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
HOUR(ADDTIME(date_placed, '01:00:00')) as hour, FROM _order
COUNT(*) as count, WHERE order_status > 15
HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour AND ${getCherryBoxClause(excludeCB)}
FROM _order AND date_placed >= NOW() - INTERVAL 24 HOUR
WHERE order_status > 15 GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
AND ${getCherryBoxClause(excludeCB)} `;
AND date_placed >= NOW() - INTERVAL 24 HOUR
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
`;
const [hourlyResult] = await connection.execute(hourlyQuery);
// Current hour in Eastern (fallback computes it directly)
const currentHour = hourlyResult.length > 0
? parseInt(hourlyResult[0].currentHour)
: DateTime.now().setZone(TIMEZONE).hour;
// Build map of hour -> count
const hourCounts = {};
hourlyResult.forEach(row => {
hourCounts[parseInt(row.hour)] = parseInt(row.count);
});
// Build array in chronological order starting from (currentHour + 1) which is 24 hours ago
hourlyOrders = [];
for (let i = 0; i < 24; i++) {
const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour
hourlyOrders.push({
hour,
count: hourCounts[hour] || 0
});
}
}
// Brands query - products.company links to product_categories.cat_id for brand name // Brands query - products.company links to product_categories.cat_id for brand name
// Only include products that have a brand assigned (INNER JOIN) // Only include products that have a brand assigned (INNER JOIN)
const brandsQuery = ` const brandsQuery = `
@@ -223,8 +200,6 @@ router.get('/stats', async (req, res) => {
LIMIT 100 LIMIT 100
`; `;
const [brandsResult] = await connection.execute(brandsQuery, params);
// Categories query - uses product_category_index to get category assignments // Categories query - uses product_category_index to get category assignments
// Only include categories with valid types (no NULL/uncategorized) // Only include categories with valid types (no NULL/uncategorized)
const categoriesQuery = ` const categoriesQuery = `
@@ -249,8 +224,6 @@ router.get('/stats', async (req, res) => {
LIMIT 100 LIMIT 100
`; `;
const [categoriesResult] = await connection.execute(categoriesQuery, params);
// Shipping locations query - uses date_shipped to match shippedCount // Shipping locations query - uses date_shipped to match shippedCount
const shippingQuery = ` const shippingQuery = `
SELECT SELECT
@@ -265,11 +238,6 @@ router.get('/stats', async (req, res) => {
GROUP BY ship_country, ship_state, ship_method_selected GROUP BY ship_country, ship_state, ship_method_selected
`; `;
const [shippingResult] = await connection.execute(shippingQuery, params);
// Process shipping data
const shippingStats = processShippingData(shippingResult, shippedCount);
// Order value range query (optionally excludes Cherry Box orders) // Order value range query (optionally excludes Cherry Box orders)
// Excludes $0 orders from min calculation // Excludes $0 orders from min calculation
const orderRangeQuery = ` const orderRangeQuery = `
@@ -280,17 +248,78 @@ router.get('/stats', async (req, res) => {
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`; `;
const [orderRangeResult] = await connection.execute(orderRangeQuery, params); // Run everything in parallel; heavier joins first so they don't tail-end
// the worker queue.
const rows = (sql, p = params) => (conn) => conn.execute(sql, p).then(([result]) => result);
const tasks = [
['brands', rows(brandsQuery)],
['categories', rows(categoriesQuery)],
['mainStats', rows(mainStatsQuery)],
['prevPeriod', (conn) => getPreviousPeriodData(conn, timeRange, startDate, endDate, excludeCB)],
['cancelled', rows(cancelledQuery)],
['refunds', rows(refundsQuery)],
['shipped', rows(shippedQuery)],
['bestDay', rows(bestDayQuery)],
['shipping', rows(shippingQuery)],
['orderRange', rows(orderRangeQuery)],
];
if (isSingleDay) {
tasks.push(['peakHour', rows(peakHourQuery)]);
tasks.push(['hourly', rows(hourlyQuery, [])]);
}
const r = await runStatsTasks(tasks, STATS_QUERY_WIDTH);
const stats = r.mainStats[0];
const cancelledStats = r.cancelled[0] || { cancelledCount: 0, cancelledTotal: 0 };
const refundStats = r.refunds;
const shippedCount = parseInt(r.shipped[0]?.shippedCount || 0);
const bestDayResult = r.bestDay;
const brandsResult = r.brands;
const categoriesResult = r.categories;
const orderRangeResult = r.orderRange;
const prevPeriodData = r.prevPeriod;
const shippingStats = processShippingData(r.shipping, shippedCount);
let peakHour = null;
if (isSingleDay && r.peakHour.length > 0) {
const hour = parseInt(r.peakHour[0].hour);
peakHour = {
hour,
count: parseInt(r.peakHour[0].count),
displayHour: DateTime.fromObject({ hour }).toFormat('h a')
};
}
// Returns data ordered chronologically: [24hrs ago, ..., 1hr ago, current hour]
let hourlyOrders = null;
if (isSingleDay) {
// Current hour in Eastern (fallback computes it directly)
const currentHour = r.hourly.length > 0
? parseInt(r.hourly[0].currentHour)
: DateTime.now().setZone(TIMEZONE).hour;
const hourCounts = {};
r.hourly.forEach(row => {
hourCounts[parseInt(row.hour)] = parseInt(row.count);
});
hourlyOrders = [];
for (let i = 0; i < 24; i++) {
const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour
hourlyOrders.push({
hour,
count: hourCounts[hour] || 0
});
}
}
// Calculate period progress for incomplete periods // Calculate period progress for incomplete periods
let periodProgress = 100; let periodProgress = 100;
if (['today', 'thisWeek', 'thisMonth'].includes(timeRange)) { if (['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
periodProgress = calculatePeriodProgress(timeRange); periodProgress = calculatePeriodProgress(timeRange);
} }
// Previous period comparison data
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
const response = { const response = {
timeRange: dateRange, timeRange: dateRange,
stats: { stats: {
@@ -384,27 +413,57 @@ router.get('/stats', async (req, res) => {
}; };
return response; return response;
} finally { }
// Always release the connection regardless of whether the outer Promise.race
// used our result. If the timeout wins, this IIFE keeps running in the
// background until MySQL responds, then this finally releases. Without it,
// every timed-out request permanently leaks one pool slot.
release();
}
};
const response = await Promise.race([mainOperation(), timeoutPromise]); // Main stats endpoint - replaces /api/klaviyo/events/stats
router.get('/stats', async (req, res) => {
const startTime = Date.now();
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
const cacheKey = `${timeRange || ''}|${startDate || ''}|${endDate || ''}|${excludeCB}`;
console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`); // Single-flight refresh: concurrent misses for the same key share one
res.json(response); // computation instead of racing multi-connection batches against the pool.
const refresh = () => {
let inflight = statsInflight.get(cacheKey);
if (inflight) return inflight;
inflight = withTimeout(computeStats(timeRange, startDate, endDate, excludeCB), 15000, '[STATS]')
.then((data) => {
statsCache.set(cacheKey, { data, timestamp: Date.now() });
// Arbitrary custom date ranges would otherwise grow the map forever
if (statsCache.size > 100) {
const oldest = [...statsCache.entries()]
.reduce((a, b) => (a[1].timestamp <= b[1].timestamp ? a : b));
statsCache.delete(oldest[0]);
}
return data;
})
.finally(() => statsInflight.delete(cacheKey));
statsInflight.set(cacheKey, inflight);
return inflight;
};
try {
const cached = statsCache.get(cacheKey);
const age = cached ? Date.now() - cached.timestamp : Infinity;
if (cached && age >= STATS_CACHE_TTL && age < STATS_CACHE_MAX_STALE) {
refresh().catch((err) => console.error('[STATS] Background refresh failed:', err.message));
}
if (cached && age < STATS_CACHE_MAX_STALE) {
console.log(`[STATS] Cache hit (${Math.round(age / 1000)}s old) for ${cacheKey}`);
return res.json(cached.data);
}
const data = await refresh();
console.log(`[STATS] Computed ${cacheKey} in ${Date.now() - startTime}ms`);
res.json(data);
} catch (error) { } catch (error) {
if (error.message.includes('timeout')) { if (error.message.includes('timeout')) {
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`); console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
} else { } else {
console.error('Error in /stats:', error); console.error('Error in /stats:', error);
} }
console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`);
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }
}); });
@@ -812,23 +871,15 @@ router.get('/products', async (req, res) => {
} }
}); });
// Projection endpoint - replaces /api/klaviyo/events/projection // Same stale-while-revalidate treatment as /stats: several tunnel round trips
router.get('/projection', async (req, res) => { // per computation (~800ms), polled every 60s by every dashboard.
const projectionCache = new Map(); // cacheKey -> { data, timestamp }
const projectionInflight = new Map(); // cacheKey -> Promise<data>
async function computeProjection(timeRange, startDate, endDate, excludeCB) {
const startTime = Date.now(); const startTime = Date.now();
let release; const { connection, release } = await getDbConnection();
try { try {
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
console.log(`[PROJECTION] Starting request for timeRange: ${timeRange}`);
// Only provide projections for incomplete periods
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' });
}
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
console.log(`[PROJECTION] DB connection obtained in ${Date.now() - startTime}ms`);
const now = DateTime.now().setZone(TIMEZONE); const now = DateTime.now().setZone(TIMEZONE);
@@ -877,15 +928,52 @@ router.get('/projection', async (req, res) => {
projection.currentRevenue = parseFloat(current.currentRevenue || 0); projection.currentRevenue = parseFloat(current.currentRevenue || 0);
projection.currentOrders = parseInt(current.currentOrders || 0); projection.currentOrders = parseInt(current.currentOrders || 0);
console.log(`[PROJECTION] Request completed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`); console.log(`[PROJECTION] Computed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
res.json(projection); return projection;
} catch (error) {
console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error);
res.status(500).json({ error: error.message });
} finally { } finally {
// Release connection back to pool release();
if (release) release(); }
}
// Projection endpoint - replaces /api/klaviyo/events/projection
router.get('/projection', async (req, res) => {
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
// Only provide projections for incomplete periods
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' });
}
const cacheKey = `${timeRange}|${excludeCB}`;
const refresh = () => {
let inflight = projectionInflight.get(cacheKey);
if (inflight) return inflight;
inflight = computeProjection(timeRange, startDate, endDate, excludeCB)
.then((data) => {
projectionCache.set(cacheKey, { data, timestamp: Date.now() });
return data;
})
.finally(() => projectionInflight.delete(cacheKey));
projectionInflight.set(cacheKey, inflight);
return inflight;
};
try {
const cached = projectionCache.get(cacheKey);
const age = cached ? Date.now() - cached.timestamp : Infinity;
if (cached && age >= STATS_CACHE_TTL && age < STATS_CACHE_MAX_STALE) {
refresh().catch((err) => console.error('[PROJECTION] Background refresh failed:', err.message));
}
if (cached && age < STATS_CACHE_MAX_STALE) {
return res.json(cached.data);
}
res.json(await refresh());
} catch (error) {
console.error('[PROJECTION] Error:', error);
res.status(500).json({ error: error.message });
} }
}); });
@@ -314,7 +314,6 @@ async function syncSettingsProductTable() {
lead_time_days, lead_time_days,
days_of_stock, days_of_stock,
safety_stock, safety_stock,
forecast_method,
exclude_from_forecast exclude_from_forecast
) )
SELECT SELECT
@@ -322,7 +321,6 @@ async function syncSettingsProductTable() {
CAST(NULL AS INTEGER), CAST(NULL AS INTEGER),
CAST(NULL AS INTEGER), CAST(NULL AS INTEGER),
COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0), COALESCE((SELECT setting_value::int FROM settings_global WHERE setting_key = 'default_safety_stock_units'), 0),
CAST(NULL AS VARCHAR),
FALSE FALSE
FROM FROM
public.products p public.products p
+7 -9
View File
@@ -136,7 +136,7 @@ router.put('/products/:pid', async (req, res) => {
const pool = req.app.locals.pool; const pool = req.app.locals.pool;
try { try {
const { pid } = req.params; const { pid } = req.params;
const { lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast } = req.body; const { lead_time_days, days_of_stock, safety_stock, exclude_from_forecast } = req.body;
console.log(`[Config Route] Updating product settings for ${pid}:`, req.body); console.log(`[Config Route] Updating product settings for ${pid}:`, req.body);
@@ -149,10 +149,10 @@ router.put('/products/:pid', async (req, res) => {
if (checkProduct.length === 0) { if (checkProduct.length === 0) {
// Insert if it doesn't exist // Insert if it doesn't exist
await pool.query( await pool.query(
`INSERT INTO settings_product `INSERT INTO settings_product
(pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast) (pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast)
VALUES ($1, $2, $3, $4, $5, $6)`, VALUES ($1, $2, $3, $4, $5)`,
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] [pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast]
); );
} else { } else {
// Update if it exists // Update if it exists
@@ -161,11 +161,10 @@ router.put('/products/:pid', async (req, res) => {
SET lead_time_days = $2, SET lead_time_days = $2,
days_of_stock = $3, days_of_stock = $3,
safety_stock = $4, safety_stock = $4,
forecast_method = $5, exclude_from_forecast = $5,
exclude_from_forecast = $6,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE pid::text = $1`, WHERE pid::text = $1`,
[pid, lead_time_days, days_of_stock, safety_stock, forecast_method, exclude_from_forecast] [pid, lead_time_days, days_of_stock, safety_stock, exclude_from_forecast]
); );
} }
@@ -190,7 +189,6 @@ router.post('/products/:pid/reset', async (req, res) => {
SET lead_time_days = NULL, SET lead_time_days = NULL,
days_of_stock = NULL, days_of_stock = NULL,
safety_stock = 0, safety_stock = 0,
forecast_method = NULL,
exclude_from_forecast = false, exclude_from_forecast = false,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE pid::text = $1`, WHERE pid::text = $1`,
+70
View File
@@ -1931,6 +1931,76 @@ router.post('/generate-upc', async (req, res) => {
} }
}); });
// Bulk "does this UPC already exist" check for the import validator.
// The one-at-a-time alternative is /search-products?q=<upc>, but that runs a
// five-column LIKE '%q%' scan across ten joined tables (~6s each) and every call
// queues on the single shared connection from getDbConnection() — a 20-product
// batch takes minutes. This answers the whole batch with one indexed IN() lookup
// on idx_upc. Matches /check-upc-and-generate-sku's semantics: `upc` only, not upc_2.
const MAX_UPCS_PER_CHECK = 500;
router.get('/check-upcs', async (req, res) => {
const { upcs } = req.query;
if (!upcs) {
return res.status(400).json({ error: 'upcs query parameter is required' });
}
// Dedupe and keep only well-formed barcodes — anything else can't match the
// column anyway, and dropping it keeps the IN() list tight.
const upcList = [...new Set(
String(upcs).split(',').map(s => s.trim()).filter(s => /^\d{8,14}$/.test(s))
)];
if (upcList.length === 0) {
return res.json({ found: {}, checked: 0 });
}
if (upcList.length > MAX_UPCS_PER_CHECK) {
return res.status(400).json({
error: `Too many UPCs (${upcList.length}); send at most ${MAX_UPCS_PER_CHECK} per request`
});
}
try {
const { connection } = await getDbConnection();
const placeholders = upcList.map(() => '?').join(',');
const [rows] = await connection.query(
`SELECT pid, upc, itemnumber, description FROM products WHERE upc IN (${placeholders})`,
upcList
);
// idx_upc is non-unique, so a UPC can legitimately hit more than one product.
// Report the lowest pid (the original) and say how many share it.
const found = {};
for (const row of rows) {
const key = String(row.upc);
const existing = found[key];
if (!existing) {
found[key] = {
pid: row.pid,
itemNumber: row.itemnumber,
title: row.description,
matchCount: 1
};
continue;
}
existing.matchCount += 1;
if (row.pid < existing.pid) {
existing.pid = row.pid;
existing.itemNumber = row.itemnumber;
existing.title = row.description;
}
}
return res.json({ found, checked: upcList.length });
} catch (error) {
console.error('Error checking UPCs:', error);
return res.status(500).json({ error: 'Failed to check UPCs', details: error.message });
}
});
// Endpoint to check UPC and generate item number // Endpoint to check UPC and generate item number
router.get('/check-upc-and-generate-sku', async (req, res) => { router.get('/check-upc-and-generate-sku', async (req, res) => {
const { upc, supplierId } = req.query; const { upc, supplierId } = req.query;
+15 -10
View File
@@ -36,6 +36,7 @@ const RepeatOrders = lazy(() => import('./pages/RepeatOrders'));
// 2. Dashboard app - separate chunk // 2. Dashboard app - separate chunk
const Dashboard = lazy(() => import('./pages/Dashboard')); const Dashboard = lazy(() => import('./pages/Dashboard'));
const SmallDashboard = lazy(() => import('./pages/SmallDashboard')); const SmallDashboard = lazy(() => import('./pages/SmallDashboard'));
const MobileDashboard = lazy(() => import('./pages/MobileDashboard'));
// 3. Product import - separate chunk // 3. Product import - separate chunk
const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import }))); const Import = lazy(() => import('./pages/Import').then(module => ({ default: module.Import })));
@@ -69,27 +70,26 @@ function App() {
}, },
}); });
if (!response.ok) { if (response.status === 401 || response.status === 403) {
// Token genuinely rejected — clear it and go to login
localStorage.removeItem('token'); localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn'); sessionStorage.removeItem('isLoggedIn');
// Only navigate to login if we're not already there // Only navigate to login if we're not already there
if (!location.pathname.includes('/login')) { if (!location.pathname.includes('/login')) {
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`); navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
} }
} else { } else if (response.ok) {
// If token is valid, set the login flag // If token is valid, set the login flag
sessionStorage.setItem('isLoggedIn', 'true'); sessionStorage.setItem('isLoggedIn', 'true');
} }
// Other statuses (500/429/etc.) are transient server issues —
// keep the token; AuthContext will retry on the next check.
} catch (error) { } catch (error) {
// Network error (server down, offline) — keep the token and let a
// later check decide. Logging out here would end valid sessions on
// every connection blip.
console.error('Token verification failed:', error); console.error('Token verification failed:', error);
localStorage.removeItem('token');
sessionStorage.removeItem('isLoggedIn');
// Only navigate to login if we're not already there
if (!location.pathname.includes('/login')) {
navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`);
}
} }
} }
}; };
@@ -109,6 +109,11 @@ function App() {
<SmallDashboard /> <SmallDashboard />
</Suspense> </Suspense>
} /> } />
<Route path="/mobile" element={
<Suspense fallback={<PageLoading />}>
<MobileDashboard />
</Suspense>
} />
<Route element={ <Route element={
<RequireAuth> <RequireAuth>
<MainLayout /> <MainLayout />
@@ -163,17 +163,17 @@ export function AiDescriptionCompare({
</div> </div>
{/* Right: AI suggestion */} {/* Right: AI suggestion */}
<div className="bg-purple-50/80 dark:bg-purple-950/30 flex flex-col w-full lg:w-1/2"> <div className="bg-purple-50/80 flex flex-col w-full lg:w-1/2">
{/* Measured header + issues area (height mirrored as spacer on the left) */} {/* Measured header + issues area (height mirrored as spacer on the left) */}
<div ref={aiHeaderRef} className="flex-shrink-0"> <div ref={aiHeaderRef} className="flex-shrink-0">
{/* Header */} {/* Header */}
<div className="w-full flex items-center justify-between px-3 py-2"> <div className="w-full flex items-center justify-between px-3 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-500" /> <Sparkles className="h-3.5 w-3.5 text-purple-500" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400"> <span className="text-xs font-medium text-purple-600">
AI Suggestion AI Suggestion
</span> </span>
<span className="text-xs text-purple-500 dark:text-purple-400"> <span className="text-xs text-purple-500">
({issues.length} {issues.length === 1 ? "issue" : "issues"}) ({issues.length} {issues.length === 1 ? "issue" : "issues"})
</span> </span>
</div> </div>
@@ -185,7 +185,7 @@ export function AiDescriptionCompare({
{issues.map((issue, index) => ( {issues.map((issue, index) => (
<div <div
key={index} key={index}
className="flex items-start gap-1.5 text-xs text-purple-600 dark:text-purple-400" className="flex items-start gap-1.5 text-xs text-purple-600"
> >
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" /> <AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span> <span>{issue}</span>
@@ -199,7 +199,7 @@ export function AiDescriptionCompare({
<div className="px-3 pb-3 flex flex-col flex-1 gap-3"> <div className="px-3 pb-3 flex flex-col flex-1 gap-3">
{/* Editable suggestion */} {/* Editable suggestion */}
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<div className="text-sm text-purple-500 dark:text-purple-400 mb-1 font-medium flex-shrink-0"> <div className="text-sm text-purple-500 mb-1 font-medium flex-shrink-0">
Suggested (editable): Suggested (editable):
</div> </div>
<Textarea <Textarea
@@ -209,7 +209,7 @@ export function AiDescriptionCompare({
setEditedSuggestion(e.target.value); setEditedSuggestion(e.target.value);
syncTextareaHeights(); syncTextareaHeights();
}} }}
className="overflow-y-auto overscroll-contain text-sm bg-white dark:bg-black/20 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 resize-y min-h-[120px] max-h-[50vh]" className="overflow-y-auto overscroll-contain text-sm bg-white border-purple-200 focus-visible:ring-purple-400 resize-y min-h-[120px] max-h-[50vh]"
/> />
</div> </div>
@@ -218,7 +218,7 @@ export function AiDescriptionCompare({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400" className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
onClick={() => onAccept(editedSuggestion)} onClick={() => onAccept(editedSuggestion)}
> >
<Check className="h-3 w-3 mr-1" /> <Check className="h-3 w-3 mr-1" />
@@ -227,7 +227,7 @@ export function AiDescriptionCompare({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400" className="h-7 px-3 text-xs text-muted-foreground hover:text-foreground"
onClick={onDismiss} onClick={onDismiss}
> >
Ignore Ignore
@@ -236,7 +236,7 @@ export function AiDescriptionCompare({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-3 text-xs text-purple-500 hover:text-purple-700 dark:text-purple-400" className="h-7 px-3 text-xs text-purple-500 hover:text-purple-700"
disabled={isRevalidating} disabled={isRevalidating}
onClick={onRevalidate} onClick={onRevalidate}
> >
@@ -209,7 +209,7 @@ export function BulkEditRow({
{state.result!.issues.map((issue, i) => ( {state.result!.issues.map((issue, i) => (
<div <div
key={i} key={i}
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400" className="flex items-start gap-1 text-[11px] text-purple-600"
> >
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" /> <AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span> <span>{issue}</span>
@@ -223,12 +223,12 @@ export function BulkEditRow({
<Input <Input
value={state.editedSuggestion ?? state.result!.suggestion!} value={state.editedSuggestion ?? state.result!.suggestion!}
onChange={(e) => onEditSuggestion(product.pid, e.target.value)} onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
className="h-7 text-sm flex-1 border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-purple-50/50 dark:bg-purple-950/20" className="h-7 text-sm flex-1 border-purple-200 focus-visible:ring-purple-400 bg-purple-50/50"
/> />
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-7 px-2 text-xs shrink-0 bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400" className="h-7 px-2 text-xs shrink-0 bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
onClick={() => onClick={() =>
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!) onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
} }
@@ -239,7 +239,7 @@ export function BulkEditRow({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-1.5 text-xs text-gray-500 hover:text-gray-700 shrink-0" className="h-7 px-1.5 text-xs text-muted-foreground hover:text-foreground shrink-0"
onClick={() => onDismiss(product.pid)} onClick={() => onDismiss(product.pid)}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
@@ -254,11 +254,11 @@ export function BulkEditRow({
if (!showSuggestion) return null; if (!showSuggestion) return null;
return ( return (
<div className="flex flex-col gap-1.5 min-w-0 flex-1 bg-purple-50/60 dark:bg-purple-950/20 rounded-md p-2"> <div className="flex flex-col gap-1.5 min-w-0 flex-1 bg-purple-50/60 rounded-md p-2">
{/* Header */} {/* Header */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Sparkles className="h-3.5 w-3.5 text-purple-500" /> <Sparkles className="h-3.5 w-3.5 text-purple-500" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400"> <span className="text-xs font-medium text-purple-600">
AI Suggestion AI Suggestion
</span> </span>
{state.result!.issues.length > 0 && ( {state.result!.issues.length > 0 && (
@@ -274,7 +274,7 @@ export function BulkEditRow({
{state.result!.issues.map((issue, i) => ( {state.result!.issues.map((issue, i) => (
<div <div
key={i} key={i}
className="flex items-start gap-1 text-[11px] text-purple-600 dark:text-purple-400" className="flex items-start gap-1 text-[11px] text-purple-600"
> >
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" /> <AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span> <span>{issue}</span>
@@ -287,7 +287,7 @@ export function BulkEditRow({
<Textarea <Textarea
value={state.editedSuggestion ?? state.result!.suggestion!} value={state.editedSuggestion ?? state.result!.suggestion!}
onChange={(e) => onEditSuggestion(product.pid, e.target.value)} onChange={(e) => onEditSuggestion(product.pid, e.target.value)}
className="text-sm min-h-[60px] max-h-[120px] resize-y border-purple-200 dark:border-purple-700 focus-visible:ring-purple-400 bg-white dark:bg-black/20" className="text-sm min-h-[60px] max-h-[120px] resize-y border-purple-200 focus-visible:ring-purple-400 bg-white"
rows={2} rows={2}
/> />
@@ -296,7 +296,7 @@ export function BulkEditRow({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-7 px-2 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400" className="h-7 px-2 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
onClick={() => onClick={() =>
onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!) onAccept(product.pid, state.editedSuggestion ?? state.result!.suggestion!)
} }
@@ -307,7 +307,7 @@ export function BulkEditRow({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-2 text-xs text-gray-500 hover:text-gray-700" className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
onClick={() => onDismiss(product.pid)} onClick={() => onDismiss(product.pid)}
> >
Dismiss Dismiss
+4 -3
View File
@@ -210,7 +210,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
case 'd': case 'd':
return <MessageSquare className="h-4 w-4 text-green-500" />; return <MessageSquare className="h-4 w-4 text-green-500" />;
default: default:
return <Hash className="h-4 w-4 text-gray-500" />; return <Hash className="h-4 w-4 text-muted-foreground" />;
} }
}; };
@@ -284,7 +284,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
return ( return (
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
{urls.map((urlData, index) => ( {urls.map((urlData, index) => (
<div key={index} className="border rounded-lg p-3 bg-gray-50"> <div key={index} className="border rounded-lg p-3 bg-surface-subtle">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<ExternalLink className="h-4 w-4 mt-1 text-blue-500 flex-shrink-0" /> <ExternalLink className="h-4 w-4 mt-1 text-blue-500 flex-shrink-0" />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -335,7 +335,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
}; };
return ( return (
<div key={index} className="border rounded-lg p-3 bg-gray-50"> <div key={index} className="border rounded-lg p-3 bg-surface-subtle">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isImage ? ( {isImage ? (
<Image className="h-4 w-4 text-green-500" /> <Image className="h-4 w-4 text-green-500" />
@@ -508,6 +508,7 @@ export function ChatRoom({ roomId, selectedUserId }: ChatRoomProps) {
<div className="flex gap-2 mt-2"> <div className="flex gap-2 mt-2">
<Input <Input
placeholder="Search messages..." placeholder="Search messages..."
className="rounded-full px-3.5"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && searchMessages()} onKeyPress={(e) => e.key === 'Enter' && searchMessages()}
+2 -2
View File
@@ -62,7 +62,7 @@ export function ChatTest({ selectedUserId }: ChatTestProps) {
case 'd': case 'd':
return <MessageSquare className="h-4 w-4 text-green-500" />; return <MessageSquare className="h-4 w-4 text-green-500" />;
default: default:
return <Users className="h-4 w-4 text-gray-500" />; return <Users className="h-4 w-4 text-muted-foreground" />;
} }
}; };
@@ -139,7 +139,7 @@ export function ChatTest({ selectedUserId }: ChatTestProps) {
{rooms.map((room) => ( {rooms.map((room) => (
<div <div
key={room.id} key={room.id}
className="flex items-center justify-between p-3 border rounded-lg hover:bg-gray-50" className="flex items-center justify-between p-3 border rounded-lg hover:bg-surface-subtle"
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{getRoomIcon(room.type)} {getRoomIcon(room.type)}
+4 -4
View File
@@ -193,8 +193,8 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
); );
default: default:
return ( return (
<div className="h-12 w-12 bg-gray-50 rounded-full flex items-center justify-center"> <div className="h-12 w-12 bg-surface-subtle rounded-full flex items-center justify-center">
<Users className="h-6 w-6 text-gray-500" /> <Users className="h-6 w-6 text-muted-foreground" />
</div> </div>
); );
} }
@@ -268,7 +268,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
return ( return (
<div key={title} className="space-y-0.5"> <div key={title} className="space-y-0.5">
<div className="flex items-center gap-2 px-2 py-1 border-b border-gray-100"> <div className="flex items-center gap-2 px-2 py-1 border-b border-muted">
{icon} {icon}
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide"> <h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{title} ({rooms.length}) {title} ({rooms.length})
@@ -281,7 +281,7 @@ export function RoomList({ selectedUserId, selectedRoomId, onRoomSelect }: RoomL
onClick={() => onRoomSelect(room.id.toString())} onClick={() => onRoomSelect(room.id.toString())}
className={` className={`
flex items-center justify-between py-0.5 px-3 rounded-lg cursor-pointer transition-colors flex items-center justify-between py-0.5 px-3 rounded-lg cursor-pointer transition-colors
hover:bg-gray-100 hover:bg-muted
${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''} ${selectedRoomId === room.id.toString() ? 'bg-blue-50 border-l-4 border-blue-500' : ''}
${(room.open === false || room.archived === true) ? 'opacity-60' : ''} ${(room.open === false || room.archived === true) ? 'opacity-60' : ''}
`} `}
@@ -35,7 +35,7 @@ export function SearchResults({ results, query, onClose, onRoomSelect }: SearchR
case 'd': case 'd':
return <MessageSquare className="h-3 w-3 text-green-500" />; return <MessageSquare className="h-3 w-3 text-green-500" />;
default: default:
return <Hash className="h-3 w-3 text-gray-500" />; return <Hash className="h-3 w-3 text-muted-foreground" />;
} }
}; };
@@ -85,7 +85,7 @@ export function SearchResults({ results, query, onClose, onRoomSelect }: SearchR
{results.map((result) => ( {results.map((result) => (
<div <div
key={result.id} key={result.id}
className="border rounded-lg p-3 hover:bg-gray-50 cursor-pointer" className="border rounded-lg p-3 hover:bg-surface-subtle cursor-pointer"
onClick={() => { onClick={() => {
onRoomSelect(result.room_id.toString()); onRoomSelect(result.room_id.toString());
onClose(); onClose();
@@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { useNow } from "@/hooks/useNow";
const formatDate = (date) => const formatDate = (date) =>
date.toLocaleDateString("en-US", { date.toLocaleDateString("en-US", {
@@ -15,7 +16,7 @@ const greeting = (date) => {
}; };
const Header = ({ right = null }) => { const Header = ({ right = null }) => {
const now = new Date(); const now = useNow();
return ( return (
<header className="px-3 pt-5 sm:px-4 lg:px-5"> <header className="px-3 pt-5 sm:px-4 lg:px-5">
<div className="flex items-center justify-between gap-4 pb-1"> <div className="flex items-center justify-between gap-4 pb-1">
@@ -1268,14 +1268,18 @@ const StatCards = ({
let isMounted = true; let isMounted = true;
const loadProjection = async () => { const loadProjection = async () => {
if (!stats?.periodProgress || stats.periodProgress >= 100) return; // The projection endpoint only serves these always-incomplete presets
// (it returns method:'none' for everything else), so key off timeRange
// instead of waiting for stats — this fires in parallel with the
// stats request rather than chained after it.
if (!["today", "thisWeek", "thisMonth"].includes(timeRange)) {
setProjection(null);
return;
}
try { try {
setProjectionLoading(true); setProjectionLoading(true);
const params = const response = await acotService.getProjection({ timeRange });
timeRange === "custom" ? { startDate, endDate } : { timeRange };
const response = await acotService.getProjection(params);
if (!isMounted) return; if (!isMounted) return;
setProjection(response); setProjection(response);
@@ -1292,7 +1296,7 @@ const StatCards = ({
return () => { return () => {
isMounted = false; isMounted = false;
}; };
}, [timeRange, startDate, endDate, stats?.periodProgress]); }, [timeRange]);
// Auto-refresh for 'today' view // Auto-refresh for 'today' view
useEffect(() => { useEffect(() => {
File diff suppressed because it is too large Load Diff
@@ -71,7 +71,7 @@ const trendPct = (cur: number, prev: number) =>
// ── tiny presentational atoms ──────────────────────────────────────────────── // ── tiny presentational atoms ────────────────────────────────────────────────
const Lbl = ({ children }: { children: React.ReactNode }) => ( const Lbl = ({ children }: { children: React.ReactNode }) => (
<div <div
className="text-[15px] font-medium uppercase" className="text-[17px] font-medium uppercase"
style={{ color: NB.mut, letterSpacing: "0.22em" }} style={{ color: NB.mut, letterSpacing: "0.22em" }}
> >
{children} {children}
@@ -197,7 +197,7 @@ const Clock = () => {
return () => clearInterval(t); return () => clearInterval(t);
}, []); }, []);
return ( 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" })} {now.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" })}
{" · "} {" · "}
<span style={{ color: NB.txt, fontWeight: 500 }}> <span style={{ color: NB.txt, fontWeight: 500 }}>
@@ -223,7 +223,6 @@ const NightboardSmall = () => {
queryKey: ["nightboard-projection-today"], queryKey: ["nightboard-projection-today"],
queryFn: async () => queryFn: async () =>
(await acotService.getProjection({ timeRange: "today" })) as Projection, (await acotService.getProjection({ timeRange: "today" })) as Projection,
enabled: stats != null && stats.periodProgress < 100,
refetchInterval: 60_000, refetchInterval: 60_000,
}); });
@@ -274,7 +273,6 @@ const NightboardSmall = () => {
queryKey: ["nightboard-projection-30d"], queryKey: ["nightboard-projection-30d"],
queryFn: async () => queryFn: async () =>
(await acotService.getProjection({ timeRange: "last30days" })) as Projection, (await acotService.getProjection({ timeRange: "last30days" })) as Projection,
enabled: sum30 != null && sum30.periodProgress < 100,
refetchInterval: 300_000, refetchInterval: 300_000,
}); });
@@ -422,6 +420,13 @@ const NightboardSmall = () => {
(stats && stats.periodProgress > 0 (stats && stats.periodProgress > 0
? Math.round(stats.orderCount / (stats.periodProgress / 100)) ? Math.round(stats.orderCount / (stats.periodProgress / 100))
: null); : null);
const ordersTrend =
stats && stats.prevPeriodOrders > 0
? trendPct(
inProgress ? projOrders ?? stats.orderCount : stats.orderCount,
stats.prevPeriodOrders
)
: null;
const rev30Current = const rev30Current =
sum30 && sum30.periodProgress < 100 sum30 && sum30.periodProgress < 100
@@ -549,7 +554,7 @@ const NightboardSmall = () => {
{/* top line */} {/* top line */}
<div className="flex items-baseline justify-between pb-6"> <div className="flex items-baseline justify-between pb-6">
<span <span
className="text-[16px] font-medium uppercase" className="text-[18px] font-medium uppercase"
style={{ color: NB.mut, letterSpacing: "0.3em" }} style={{ color: NB.mut, letterSpacing: "0.3em" }}
> >
A Cherry On Top A Cherry On Top
@@ -567,42 +572,42 @@ const NightboardSmall = () => {
<div> <div>
<Lbl>Revenue today</Lbl> <Lbl>Revenue today</Lbl>
<div <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" }} style={{ color: NB.amberText, letterSpacing: "-0.03em" }}
> >
{fmtMoney(stats?.revenue)} {fmtMoney(stats?.revenue)}
</div> </div>
<div className="text-[18px] mt-3" style={{ color: NB.mut }}> <div className="text-[20px] mt-3" style={{ color: NB.mut }}>
{inProgress && projRevenue != null && ( {inProgress && projRevenue != null && (
<> <>
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtMoney(projRevenue)}</span> 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> </div>
<div> <div>
<Lbl>Orders</Lbl> <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() ?? "—"} {stats?.orderCount?.toLocaleString() ?? "—"}
</div> </div>
<div className="text-[17px] mt-2" style={{ color: NB.mut }}> <div className="text-[19px] mt-2" style={{ color: NB.mut }}>
{projOrders != null && inProgress && ( {projOrders != null && inProgress && (
<> <>
proj <span style={{ color: NB.txt, fontWeight: 500 }}>{projOrders}</span> 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> </div>
<div> <div>
<Lbl>Avg order</Lbl> <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)}` : "—"} {stats?.averageOrderValue != null ? `$${stats.averageOrderValue.toFixed(2)}` : "—"}
</div> </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 != null
? `${stats.averageItemsPerOrder.toFixed(1)} items / order` ? `${stats.averageItemsPerOrder.toFixed(1)} items / order`
: ""} : ""}
@@ -610,7 +615,7 @@ const NightboardSmall = () => {
</div> </div>
<div> <div>
<Lbl>On site now</Lbl> <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="relative inline-flex h-3 w-3">
<span <span
className="absolute inline-flex h-full w-full rounded-full opacity-75 motion-safe:animate-ping" className="absolute inline-flex h-full w-full rounded-full opacity-75 motion-safe:animate-ping"
@@ -620,7 +625,7 @@ const NightboardSmall = () => {
</span> </span>
{realtime?.last5MinUsers ?? "—"} {realtime?.last5MinUsers ?? "—"}
</div> </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` : ""} {realtime != null ? `${realtime.last30MinUsers} last 30 min` : ""}
</div> </div>
</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 flex-col min-h-0 pt-4" style={{ borderTop: `1px solid ${NB.line}` }}>
<div className="flex items-baseline justify-between pb-1"> <div className="flex items-baseline justify-between pb-1">
<Lbl>Last 30 days</Lbl> <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>{" "} Revenue <span style={{ color: NB.txt, fontWeight: 500 }}>{fmtCompact(sum30?.revenue)}</span>{" "}
<TrendArrow pct={rev30Trend} /> <TrendArrow pct={rev30Trend} />
{" · "} {" · "}
@@ -652,17 +657,17 @@ const NightboardSmall = () => {
tickFormatter={(v: string) => tickFormatter={(v: string) =>
new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" }) new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" })
} }
tick={{ fill: NB.mut, fontSize: 14 }} tick={{ fill: NB.mut, fontSize: 16 }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
interval={5} interval={5}
/> />
<YAxis <YAxis
tickFormatter={(v: number) => fmtCompact(v)} tickFormatter={(v: number) => fmtCompact(v)}
tick={{ fill: NB.mut, fontSize: 14 }} tick={{ fill: NB.mut, fontSize: 16 }}
tickLine={false} tickLine={false}
axisLine={false} axisLine={false}
width={52} width={58}
/> />
<Tooltip <Tooltip
cursor={{ stroke: NB.faint, strokeDasharray: "3 4" }} cursor={{ stroke: NB.faint, strokeDasharray: "3 4" }}
@@ -671,7 +676,7 @@ const NightboardSmall = () => {
const row = payload[0].payload as { date: string; total: number }; const row = payload[0].payload as { date: string; total: number };
return ( return (
<div <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 }} style={{ background: NB.card, border: `1px solid ${NB.line}`, color: NB.txt }}
> >
<div style={{ color: NB.mut }}> <div style={{ color: NB.mut }}>
@@ -713,7 +718,7 @@ const NightboardSmall = () => {
/> />
))} ))}
</div> </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) => ( {activePhases.slice(0, 5).map((p) => (
<span key={p.phase} className="flex items-center gap-2 whitespace-nowrap"> <span key={p.phase} className="flex items-center gap-2 whitespace-nowrap">
<span <span
@@ -739,15 +744,15 @@ const NightboardSmall = () => {
}} }}
> >
<div <div
className="text-[13px] font-medium uppercase" className="text-[15px] font-medium uppercase"
style={{ color: NB.mut, letterSpacing: "0.18em" }} style={{ color: NB.mut, letterSpacing: "0.18em" }}
> >
{cell.label} {cell.label}
</div> </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} {cell.value}
</div> </div>
<div className="text-[15px] mt-1" style={{ color: NB.mut }}> <div className="text-[17px] mt-1" style={{ color: NB.mut }}>
{cell.sub} {cell.sub}
</div> </div>
</div> </div>
@@ -811,11 +816,11 @@ const NightboardSmall = () => {
return ( return (
<EventDialog key={event.id} event={event} scale={1.75}> <EventDialog key={event.id} event={event} scale={1.75}>
<div <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 }} style={{ background: NB.card }}
> >
<div <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" }} style={{ color: NB.mut, letterSpacing: "0.16em" }}
> >
<span className="flex items-center gap-2.5"> <span className="flex items-center gap-2.5">
@@ -834,8 +839,8 @@ const NightboardSmall = () => {
</span> </span>
)} )}
</div> </div>
<div className="text-[21px] font-medium mt-2 truncate">{name || "—"}</div> <div className="text-[23px] font-medium mt-2 truncate">{name || "—"}</div>
<div className="text-[16px] mt-1 truncate" style={{ color: NB.mut }}> <div className="text-[18px] mt-1 truncate" style={{ color: NB.mut }}>
{detail} {detail}
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@ export function BusinessRangeSelect({
<SelectTrigger <SelectTrigger
id={id} id={id}
className={cn( 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 className
)} )}
> >
@@ -112,22 +112,22 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
}) => { }) => {
const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3"; const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3";
const titleClass = size === "large" const titleClass = size === "large"
? "text-[14px] font-semibold tracking-tight text-[#2b2925]" ? "text-[14px] font-semibold tracking-tight text-foreground"
: "text-[12.5px] font-semibold tracking-tight text-[#2b2925]"; : "text-[12.5px] font-semibold tracking-tight text-foreground";
void accent; // Studio design has no accent bar; prop kept for call-site compat void accent; // Studio design has no accent bar; prop kept for call-site compat
// Loading skeleton // Loading skeleton
if (loading) { if (loading) {
return ( return (
<CardHeader className={cn("border-b border-[#f0eeea]", paddingClass, className)}> <CardHeader className={cn("border-b border-muted", paddingClass, className)}>
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<div className="space-y-1"> <div className="space-y-1">
<Skeleton className="h-5 w-40 bg-[#f0eeea]" /> <Skeleton className="h-5 w-40 bg-muted" />
{description && <Skeleton className="h-4 w-56 bg-[#f5f3f0]" />} {description && <Skeleton className="h-4 w-56 bg-muted/60" />}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{timeSelector && <Skeleton className="h-8 w-[130px] bg-[#f0eeea] rounded-full" />} {timeSelector && <Skeleton className="h-8 w-[130px] bg-muted rounded-full" />}
{actions && <Skeleton className="h-8 w-20 bg-[#f0eeea] rounded-full" />} {actions && <Skeleton className="h-8 w-20 bg-muted rounded-full" />}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -137,14 +137,14 @@ export const DashboardSectionHeader: React.FC<DashboardSectionHeaderProps> = ({
const hasRightContent = timeSelector || actions; const hasRightContent = timeSelector || actions;
return ( 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"> <div className="flex flex-row items-center justify-between gap-3">
{/* Left side: title (+ optional description / updated time inline) */} {/* Left side: title (+ optional description / updated time inline) */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex flex-wrap items-baseline gap-x-2.5"> <div className="flex flex-wrap items-baseline gap-x-2.5">
<CardTitle className={titleClass}>{title}</CardTitle> <CardTitle className={titleClass}>{title}</CardTitle>
{lastUpdated && !loading && ( {lastUpdated && !loading && (
<span className="text-[10.5px] text-[#a09b92]"> <span className="text-[10.5px] text-muted-foreground/75">
{lastUpdatedFormat(lastUpdated)} {lastUpdatedFormat(lastUpdated)}
</span> </span>
)} )}
@@ -191,15 +191,15 @@ export const DashboardSectionHeaderSkeleton: React.FC<DashboardSectionHeaderSkel
const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3"; const paddingClass = compact ? "px-4 py-2.5" : "px-4 py-3";
return ( 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="flex justify-between items-start">
<div className="space-y-2"> <div className="space-y-2">
<Skeleton className="h-5 w-40 bg-[#f0eeea]" /> <Skeleton className="h-5 w-40 bg-muted" />
{hasDescription && <Skeleton className="h-4 w-56 bg-[#f5f3f0]" />} {hasDescription && <Skeleton className="h-4 w-56 bg-muted/60" />}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{hasTimeSelector && <Skeleton className="h-8 w-[130px] bg-[#f0eeea] rounded-full" />} {hasTimeSelector && <Skeleton className="h-8 w-[130px] bg-muted rounded-full" />}
{hasActions && <Skeleton className="h-8 w-20 bg-[#f0eeea] rounded-full" />} {hasActions && <Skeleton className="h-8 w-20 bg-muted rounded-full" />}
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -136,9 +136,9 @@ export const DashboardStatCard: React.FC<DashboardStatCardProps> = ({
: "text-[20px]"; : "text-[20px]";
const cardClass = cn( const cardClass = cn(
"rounded-[14px] border bg-white px-3.5 py-3", "rounded-[14px] border bg-card px-3.5 py-3",
onClick && 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 className
); );
const cardStyle = { borderColor: STUDIO_COLORS.border }; const cardStyle = { borderColor: STUDIO_COLORS.border };
@@ -147,9 +147,9 @@ export const DashboardStatCard: React.FC<DashboardStatCardProps> = ({
if (loading) { if (loading) {
return ( return (
<div className={cn(cardClass, "min-h-[92px]")} style={cardStyle}> <div className={cn(cardClass, "min-h-[92px]")} style={cardStyle}>
<div className="h-3.5 w-20 animate-pulse rounded bg-[#f0eeea]" /> <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-[#f0eeea]" /> <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-[#f5f3f0]" />} {subtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-muted/60" />}
</div> </div>
); );
} }
@@ -219,12 +219,12 @@ export const DashboardStatCardSkeleton: React.FC<DashboardStatCardSkeletonProps>
className, className,
}) => ( }) => (
<div <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 }} style={{ borderColor: STUDIO_COLORS.border }}
> >
<div className="h-3.5 w-20 animate-pulse rounded bg-[#f0eeea]" /> <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-[#f0eeea]" /> <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-[#f5f3f0]" />} {hasSubtitle && <div className="mt-2 h-3.5 w-24 animate-pulse rounded bg-muted/60" />}
</div> </div>
); );
@@ -12,10 +12,10 @@ import React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export const PILL_TRIGGER_CLASS = 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 = 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 { export interface MetricPillProps {
active: boolean; active: boolean;
@@ -39,8 +39,8 @@ export const MetricPill: React.FC<MetricPillProps> = ({
className={cn( className={cn(
"flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors", "flex h-7 shrink-0 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors",
active active
? "bg-[#f0eeea] text-[#2b2925] hover:bg-[#eae7e2]" ? "bg-muted text-foreground hover:bg-[#eae7e2]"
: "text-[#7c7870] hover:bg-[#f5f3f0] hover:text-[#2b2925]" : "text-muted-foreground hover:bg-muted/60 hover:text-foreground"
)} )}
> >
<span <span
@@ -63,7 +63,7 @@ export interface LegendChipProps {
/** Static series chip — for charts whose series aren't toggleable */ /** Static series chip — for charts whose series aren't toggleable */
export const LegendChip: React.FC<LegendChipProps> = ({ color, dashed = false, children }) => ( 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 <span
className="inline-block h-2 w-2 rounded-full" className="inline-block h-2 w-2 rounded-full"
style={ style={
@@ -87,7 +87,7 @@ export interface QuietStatProps {
/** /**
* Deemphasized stat cell. Place inside: * Deemphasized stat cell. Place inside:
* <div className="grid grid-cols-2 gap-px overflow-hidden rounded-[14px] * <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 }) => { export const QuietStat: React.FC<QuietStatProps> = ({ label, value, sub, onClick, loading }) => {
const Tag = onClick ? "button" : "div"; const Tag = onClick ? "button" : "div";
@@ -95,20 +95,20 @@ export const QuietStat: React.FC<QuietStatProps> = ({ label, value, sub, onClick
<Tag <Tag
{...(onClick ? { type: "button" as const, onClick } : {})} {...(onClick ? { type: "button" as const, onClick } : {})}
className={cn( className={cn(
"bg-white px-3.5 py-2.5 text-left", "bg-card px-3.5 py-2.5 text-left",
onClick && 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 ? ( {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} {value}
</div> </div>
{sub && <div className="text-[10.5px] text-[#7c7870]">{sub}</div>} {sub && <div className="text-[10.5px] text-muted-foreground">{sub}</div>}
</> </>
)} )}
</Tag> </Tag>
@@ -116,4 +116,4 @@ export const QuietStat: React.FC<QuietStatProps> = ({ label, value, sub, onClick
}; };
export const QUIET_STRIP_CLASS = 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( className={cn(
"sticky left-0 z-10 border-r px-3 py-2", "sticky left-0 z-10 border-r px-3 py-2",
groupStyle.rowClass, groupStyle.rowClass,
isImportant ? "font-semibold text-emerald-800" : "font-medium text-slate-700" isImportant ? "font-semibold text-emerald-800" : "font-medium text-foreground/90"
)} )}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -243,7 +243,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
<span <span
className={cn( className={cn(
"text-sm leading-tight pr-2", "text-sm leading-tight pr-2",
isImportant ? "font-semibold text-emerald-900" : "font-medium text-slate-800" isImportant ? "font-semibold text-emerald-900" : "font-medium text-foreground"
)} )}
> >
{row.label} {row.label}
@@ -275,7 +275,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
<TableCell <TableCell
key={`${row.key}-${bucket.key}`} key={`${row.key}-${bucket.key}`}
className={cn( className={cn(
"text-center whitespace-nowrap px-1 py-1 text-slate-700", "text-center whitespace-nowrap px-1 py-1 text-foreground/90",
groupStyle.rowClass, groupStyle.rowClass,
isImportant && "font-semibold text-emerald-700" isImportant && "font-semibold text-emerald-700"
)} )}
@@ -294,7 +294,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
<TableCell <TableCell
key={`${row.key}-${bucket.key}`} key={`${row.key}-${bucket.key}`}
className={cn( className={cn(
"text-center whitespace-nowrap px-1 py-1 text-slate-700", "text-center whitespace-nowrap px-1 py-1 text-foreground/90",
groupStyle.rowClass, groupStyle.rowClass,
isImportant && "font-semibold text-emerald-700" isImportant && "font-semibold text-emerald-700"
)} )}
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -62,17 +63,18 @@ export function BestSellers() {
return ( return (
<> <>
<Tabs defaultValue="products"> <Tabs defaultValue="products">
<CardHeader> <SectionHeader
<div className="flex flex-row items-center justify-between"> title="Best Sellers"
<CardTitle className="text-lg font-medium">Best Sellers</CardTitle> size="large"
actions={
<TabsList> <TabsList>
<TabsTrigger value="products">Products</TabsTrigger> <TabsTrigger value="products">Products</TabsTrigger>
<TabsTrigger value="brands">Brands</TabsTrigger> <TabsTrigger value="brands">Brands</TabsTrigger>
<TabsTrigger value="categories">Categories</TabsTrigger> <TabsTrigger value="categories">Categories</TabsTrigger>
</TabsList> </TabsList>
</div> }
</CardHeader> />
<CardContent> <CardContent className="pt-4">
{isError ? ( {isError ? (
<p className="text-sm text-destructive">Failed to load best sellers</p> <p className="text-sm text-destructive">Failed to load best sellers</p>
) : isLoading ? ( ) : isLoading ? (
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts" import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
import { useState } from "react" import { useState } from "react"
import config from "@/config" import config from "@/config"
@@ -87,9 +88,10 @@ export function ForecastMetrics() {
return ( return (
<> <>
<CardHeader className="flex flex-row items-center justify-between pr-5"> <SectionHeader
<CardTitle className="text-xl font-medium">Forecast</CardTitle> title="Forecast"
<div className="flex items-center gap-2"> size="large"
actions={
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8"> <Button variant="ghost" size="icon" className="h-8 w-8">
@@ -100,6 +102,8 @@ export function ForecastMetrics() {
<ForecastAccuracy /> <ForecastAccuracy />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
}
timeSelector={
<Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}> <Tabs value={String(period)} onValueChange={(v) => setPeriod(v === 'year' ? 'year' : Number(v) as Period)}>
<TabsList> <TabsList>
<TabsTrigger value="30">30D</TabsTrigger> <TabsTrigger value="30">30D</TabsTrigger>
@@ -107,9 +111,9 @@ export function ForecastMetrics() {
<TabsTrigger value="year">EOY</TabsTrigger> <TabsTrigger value="year">EOY</TabsTrigger>
</TabsList> </TabsList>
</Tabs> </Tabs>
</div> }
</CardHeader> />
<CardContent className="py-0 -mb-2"> <CardContent className="pt-3 pb-0 -mb-2">
{error ? ( {error ? (
<div className="text-sm text-red-500">Error: {error.message}</div> <div className="text-sm text-red-500">Error: {error.message}</div>
) : ( ) : (
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/utils/formatCurrency" import { formatCurrency } from "@/utils/formatCurrency"
import { AlertTriangle, Layers, DollarSign, Tag } from "lucide-react" import { AlertTriangle, Layers, DollarSign, Tag } from "lucide-react"
@@ -47,10 +48,8 @@ export function OverstockMetrics() {
return ( return (
<> <>
<CardHeader> <SectionHeader title="Overstock" size="large" />
<CardTitle className="text-xl font-medium">Overstock</CardTitle> <CardContent className="pt-4">
</CardHeader>
<CardContent>
{isError ? ( {isError ? (
<p className="text-sm text-destructive">Failed to load overstock metrics</p> <p className="text-sm text-destructive">Failed to load overstock metrics</p>
) : ( ) : (
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/utils/formatCurrency" import { formatCurrency } from "@/utils/formatCurrency"
@@ -84,10 +85,8 @@ export function PurchaseMetrics() {
return ( return (
<> <>
<CardHeader> <SectionHeader title="Purchases" size="large" />
<CardTitle className="text-xl font-medium">Purchases</CardTitle> <CardContent className="pt-4">
</CardHeader>
<CardContent>
{isError ? ( {isError ? (
<p className="text-sm text-destructive">Failed to load purchase metrics</p> <p className="text-sm text-destructive">Failed to load purchase metrics</p>
) : ( ) : (
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/utils/formatCurrency" import { formatCurrency } from "@/utils/formatCurrency"
import { PackagePlus, DollarSign, Tag } from "lucide-react" import { PackagePlus, DollarSign, Tag } from "lucide-react"
@@ -49,10 +50,8 @@ export function ReplenishmentMetrics() {
return ( return (
<> <>
<CardHeader> <SectionHeader title="Replenishment" size="large" />
<CardTitle className="text-xl font-medium">Replenishment</CardTitle> <CardContent className="pt-4">
</CardHeader>
<CardContent>
{isError ? ( {isError ? (
<p className="text-sm text-destructive">Failed to load replenishment metrics</p> <p className="text-sm text-destructive">Failed to load replenishment metrics</p>
) : ( ) : (
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts" import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip as RechartsTooltip } from "recharts"
import { useState } from "react" import { useState } from "react"
import config from "@/config" import config from "@/config"
@@ -72,11 +73,14 @@ export function SalesMetrics() {
return ( return (
<> <>
<CardHeader className="flex flex-row items-center justify-between pr-5"> <SectionHeader
<CardTitle className="text-xl font-medium">Sales</CardTitle> title="Sales"
<HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} /> size="large"
</CardHeader> timeSelector={
<CardContent className="py-0 -mb-2"> <HorizonTabs value={period} onChange={(d) => setPeriod(d as Period)} options={[7, 30, 90]} />
}
/>
<CardContent className="pt-3 pb-0 -mb-2">
{isError ? ( {isError ? (
<p className="text-sm text-destructive">Failed to load sales metrics</p> <p className="text-sm text-destructive">Failed to load sales metrics</p>
) : ( ) : (
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts" import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"
import config from "@/config" import config from "@/config"
import { formatCurrency } from "@/utils/formatCurrency" import { formatCurrency } from "@/utils/formatCurrency"
@@ -116,10 +117,8 @@ export function StockMetrics() {
return ( return (
<> <>
<CardHeader> <SectionHeader title="Stock" size="large" />
<CardTitle className="text-xl font-medium">Stock</CardTitle> <CardContent className="pt-4">
</CardHeader>
<CardContent>
{isError ? ( {isError ? (
<p className="text-sm text-destructive">Failed to load stock metrics</p> <p className="text-sm text-destructive">Failed to load stock metrics</p>
) : ( ) : (
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config" import config from "@/config"
@@ -38,10 +39,8 @@ export function TopOverstockedProducts() {
return ( return (
<> <>
<CardHeader> <SectionHeader title="Top Overstocked Products" size="large" />
<CardTitle className="text-xl font-medium">Top Overstocked Products</CardTitle> <CardContent className="pt-4">
</CardHeader>
<CardContent>
{isError ? ( {isError ? (
<p className="text-sm text-destructive">Failed to load overstocked products</p> <p className="text-sm text-destructive">Failed to load overstocked products</p>
) : isLoading ? ( ) : isLoading ? (
@@ -1,6 +1,7 @@
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { apiFetch } from '@/utils/api'; import { apiFetch } from '@/utils/api';
import { CardHeader, CardTitle, CardContent } from "@/components/ui/card" import { CardContent } from "@/components/ui/card"
import { SectionHeader } from "@/components/shared"
import { ScrollArea } from "@/components/ui/scroll-area" import { ScrollArea } from "@/components/ui/scroll-area"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import config from "@/config" import config from "@/config"
@@ -38,10 +39,8 @@ export function TopReplenishProducts() {
return ( return (
<> <>
<CardHeader> <SectionHeader title="Top Products To Replenish" size="large" />
<CardTitle className="text-xl font-medium">Top Products To Replenish</CardTitle> <CardContent className="pt-4">
</CardHeader>
<CardContent>
{isError ? ( {isError ? (
<p className="text-sm text-destructive">Failed to load replenish products</p> <p className="text-sm text-destructive">Failed to load replenish products</p>
) : isLoading ? ( ) : isLoading ? (
@@ -190,7 +190,7 @@ export function EditableMultiSelect({
{/* Selected items pinned to top */} {/* Selected items pinned to top */}
{value.length > 0 && ( {value.length > 0 && (
<CommandGroup> <CommandGroup>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50/80 dark:bg-green-950/40 border-b border-green-100 dark:border-green-900"> <div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 bg-green-50/80 border-b border-green-100">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
<span>Selected ({value.length})</span> <span>Selected ({value.length})</span>
</div> </div>
@@ -204,7 +204,7 @@ export function EditableMultiSelect({
key={`sel-${selectedVal}`} key={`sel-${selectedVal}`}
value={`sel-${opt?.label ?? selectedVal}`} value={`sel-${opt?.label ?? selectedVal}`}
onSelect={() => handleSelect(selectedVal)} onSelect={() => handleSelect(selectedVal)}
className="bg-green-50/50 dark:bg-green-950/30" className="bg-green-50/50"
> >
<Check className="mr-2 h-4 w-4 opacity-100 text-green-600" /> <Check className="mr-2 h-4 w-4 opacity-100 text-green-600" />
{hex && ( {hex && (
@@ -226,7 +226,7 @@ export function EditableMultiSelect({
{/* AI Suggestions section */} {/* AI Suggestions section */}
{(suggestions && suggestions.length > 0) || isLoadingSuggestions ? ( {(suggestions && suggestions.length > 0) || isLoadingSuggestions ? (
<CommandGroup> <CommandGroup>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900"> <div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 bg-purple-50/80 border-b border-purple-100">
<Sparkles className="h-3 w-3" /> <Sparkles className="h-3 w-3" />
<span>Suggested</span> <span>Suggested</span>
{isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />} {isLoadingSuggestions && <Loader2 className="h-3 w-3 animate-spin" />}
@@ -242,7 +242,7 @@ export function EditableMultiSelect({
key={`suggestion-${suggestion.id}`} key={`suggestion-${suggestion.id}`}
value={`suggestion-${suggestion.name}`} value={`suggestion-${suggestion.name}`}
onSelect={() => handleSelect(String(suggestion.id))} onSelect={() => handleSelect(String(suggestion.id))}
className="bg-purple-50/30 dark:bg-purple-950/20" className="bg-purple-50/30"
> >
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<Check className="h-4 w-4 flex-shrink-0 opacity-0" /> <Check className="h-4 w-4 flex-shrink-0 opacity-0" />
@@ -259,7 +259,7 @@ export function EditableMultiSelect({
{showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)} {showColors ? suggestion.name : (suggestion.fullPath || suggestion.name)}
</span> </span>
</div> </div>
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0"> <span className="text-xs text-purple-500 ml-2 flex-shrink-0">
{similarityPercent}% {similarityPercent}%
</span> </span>
</CommandItem> </CommandItem>
@@ -703,7 +703,7 @@ export function ProductEditForm({
<button <button
type="button" type="button"
onClick={() => setDescDialogOpen(true)} onClick={() => setDescDialogOpen(true)}
className="flex items-center gap-1 px-1.5 -mr-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors shrink-0" className="flex items-center gap-1 px-1.5 -mr-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 text-purple-600 text-xs transition-colors shrink-0"
title="View AI suggestion" title="View AI suggestion"
> >
<Sparkles className="h-3.5 w-3.5" /> <Sparkles className="h-3.5 w-3.5" />
@@ -140,6 +140,7 @@ export function ProductSearch({
<div className="flex gap-3"> <div className="flex gap-3">
<Input <Input
placeholder="Search products…" placeholder="Search products…"
className="rounded-full px-3.5"
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()} onKeyDown={(e) => e.key === "Enter" && handleSearch()}
@@ -185,11 +186,11 @@ export function ProductSearch({
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="sticky top-0 bg-background">Name</TableHead> <TableHead className="sticky top-0 bg-card">Name</TableHead>
<TableHead className="sticky top-0 bg-background">Item Number</TableHead> <TableHead className="sticky top-0 bg-card">Item Number</TableHead>
<TableHead className="sticky top-0 bg-background">Brand</TableHead> <TableHead className="sticky top-0 bg-card">Brand</TableHead>
<TableHead className="sticky top-0 bg-background">Line</TableHead> <TableHead className="sticky top-0 bg-card">Line</TableHead>
<TableHead className="sticky top-0 bg-background text-right"> <TableHead className="sticky top-0 bg-card text-right">
Price Price
</TableHead> </TableHead>
</TableRow> </TableRow>
@@ -70,7 +70,7 @@ export const GenericDropzone = ({
e.stopPropagation(); e.stopPropagation();
onShowUnassigned(); onShowUnassigned();
}} }}
className="mt-2 text-amber-600 dark:text-amber-400" className="mt-2 text-amber-600"
> >
Show {unassignedImages.length} unassigned {unassignedImages.length === 1 ? 'image' : 'images'} Show {unassignedImages.length} unassigned {unassignedImages.length === 1 ? 'image' : 'images'}
</Button> </Button>
@@ -43,8 +43,8 @@ export const CopyButton = ({ text }: CopyButtonProps) => {
className={`ml-1 inline-flex items-center justify-center rounded-full p-1 transition-colors ${ className={`ml-1 inline-flex items-center justify-center rounded-full p-1 transition-colors ${
canCopy canCopy
? isCopied ? isCopied
? "bg-green-100 text-green-600 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400" ? "bg-green-100 text-green-600 hover:bg-green-200"
: "text-muted-foreground hover:bg-gray-100 dark:hover:bg-gray-800" : "text-muted-foreground hover:bg-gray-100"
: "opacity-50 cursor-not-allowed" : "opacity-50 cursor-not-allowed"
}`} }`}
disabled={!canCopy} disabled={!canCopy}
@@ -201,7 +201,7 @@ export const SortableImage = ({
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black"> <div className="overflow-hidden rounded-md border border-border shadow-md bg-white">
<img <img
src={getFullImageUrl(image.url || image.imageUrl || '')} src={getFullImageUrl(image.url || image.imageUrl || '')}
alt={`${displayName} - Image ${imgIndex + 1}`} alt={`${displayName} - Image ${imgIndex + 1}`}
@@ -24,11 +24,11 @@ export const UnassignedImagesSection = ({
return ( return (
<div className="mb-4 px-4"> <div className="mb-4 px-4">
<div className="bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-md p-3"> <div className="bg-amber-50 border border-amber-200 rounded-md p-3">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5 text-amber-500" /> <AlertCircle className="h-5 w-5 text-amber-500" />
<h3 className="text-sm font-medium text-amber-700 dark:text-amber-400"> <h3 className="text-sm font-medium text-amber-700">
Unassigned Images ({unassignedImages.length}) Unassigned Images ({unassignedImages.length})
</h3> </h3>
</div> </div>
@@ -43,7 +43,7 @@ export const UnassignedImageItem = ({
<p className="text-white text-xs mb-1 truncate">{image.file.name}</p> <p className="text-white text-xs mb-1 truncate">{image.file.name}</p>
<div className="flex gap-2"> <div className="flex gap-2">
<Select onValueChange={(value) => onAssign(index, parseInt(value))}> <Select onValueChange={(value) => onAssign(index, parseInt(value))}>
<SelectTrigger className="h-7 text-xs bg-white dark:bg-gray-800 border-0 text-black dark:text-white"> <SelectTrigger className="h-7 text-xs bg-white border-0 text-black">
<SelectValue placeholder="Assign to..." /> <SelectValue placeholder="Assign to..." />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -57,7 +57,7 @@ export const UnassignedImageItem = ({
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-7 w-7 bg-white dark:bg-gray-800 text-black dark:text-white" className="h-7 w-7 bg-white text-black"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onRemove(index); onRemove(index);
@@ -94,7 +94,7 @@ export const UnassignedImageItem = ({
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
<div className="overflow-hidden rounded-md border border-border shadow-md bg-white dark:bg-black"> <div className="overflow-hidden rounded-md border border-border shadow-md bg-white">
<img <img
src={image.previewUrl} src={image.previewUrl}
alt={`Unassigned image: ${image.file.name}`} alt={`Unassigned image: ${image.file.name}`}
@@ -1510,7 +1510,7 @@ const MatchColumnsStepComponent = <T extends string>({
type="checkbox" type="checkbox"
checked={!!globalSelections.costIsTotalCost} checked={!!globalSelections.costIsTotalCost}
onChange={(e) => setGlobalSelections(prev => ({ ...prev, costIsTotalCost: e.target.checked }))} onChange={(e) => setGlobalSelections(prev => ({ ...prev, costIsTotalCost: e.target.checked }))}
className="h-3.5 w-3.5 rounded border-gray-300" className="h-3.5 w-3.5 rounded border-input"
/> />
Divide by min qty Divide by min qty
</label> </label>
@@ -1561,7 +1561,7 @@ const MatchColumnsStepComponent = <T extends string>({
return ( return (
<React.Fragment key={`unmapped-values-${column.index}`}> <React.Fragment key={`unmapped-values-${column.index}`}>
<TableRow className="bg-amber-50 dark:bg-amber-950/20"> <TableRow className="bg-amber-50">
<TableCell className="font-medium">{column.header}</TableCell> <TableCell className="font-medium">{column.header}</TableCell>
<TableCell className="text-center"> <TableCell className="text-center">
{renderSamplePreview(column.index)} {renderSamplePreview(column.index)}
@@ -1944,7 +1944,7 @@ const MatchColumnsStepComponent = <T extends string>({
) : ( ) : (
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0" /> <AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0" />
)} )}
<span className={`text-sm ${isAccountedFor ? "text-green-700 dark:text-green-400" : "text-amber-700 dark:text-amber-400"}`}> <span className={`text-sm ${isAccountedFor ? "text-green-700" : "text-amber-700"}`}>
{getFieldLabel(field.key)} {getFieldLabel(field.key)}
</span> </span>
<span className="text-xs text-muted-foreground ml-auto"> <span className="text-xs text-muted-foreground ml-auto">
@@ -77,8 +77,8 @@ export const TemplateColumn = <T extends string>({ column, onChange, onSubChange
</Select> </Select>
</div> </div>
{isChecked && ( {isChecked && (
<div className="flex h-8 w-8 items-center justify-center rounded-full border border-green-700 bg-green-300 dark:bg-green-900/20"> <div className="flex h-8 w-8 items-center justify-center rounded-full border border-green-700 bg-green-300">
<Check className="h-4 w-4 text-green-700 dark:text-green-500" /> <Check className="h-4 w-4 text-green-700" />
</div> </div>
)} )}
</CardHeader> </CardHeader>
@@ -66,7 +66,7 @@ export function AiSuggestionBadge({
className={cn( className={cn(
'flex items-center justify-between gap-1.5 px-2 py-1 rounded-md text-xs', 'flex items-center justify-between gap-1.5 px-2 py-1 rounded-md text-xs',
'bg-purple-50 border border-purple-200', 'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800', '',
className className
)} )}
> >
@@ -77,7 +77,7 @@ export function AiSuggestionBadge({
type="text" type="text"
value={editedValue} value={editedValue}
onChange={(e) => setEditedValue(e.target.value)} onChange={(e) => setEditedValue(e.target.value)}
className="flex-1 min-w-0 bg-transparent text-purple-700 dark:text-purple-300 text-xs outline-none border-b border-transparent focus:border-purple-300 dark:focus:border-purple-600 transition-colors" className="flex-1 min-w-0 bg-transparent text-purple-700 text-xs outline-none border-b border-transparent focus:border-purple-300 transition-colors"
/> />
</div> </div>
<div className="flex items-center gap-[0px] flex-shrink-0"> <div className="flex items-center gap-[0px] flex-shrink-0">
@@ -107,7 +107,7 @@ export function AiSuggestionBadge({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-4 w-4 p-0 [&_svg]:size-3.5 text-gray-400 hover:text-gray-600 hover:bg-gray-100" className="h-4 w-4 p-0 [&_svg]:size-3.5 text-muted-foreground/70 hover:text-foreground hover:bg-muted"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDismiss(); onDismiss();
@@ -201,14 +201,14 @@ export function AiSuggestionBadge({
className={cn( className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs', 'flex items-center gap-1.5 px-2 py-1 rounded-md text-xs',
'bg-purple-50 border border-purple-200 hover:bg-purple-100', 'bg-purple-50 border border-purple-200 hover:bg-purple-100',
'dark:bg-purple-950/30 dark:border-purple-800 dark:hover:bg-purple-900/40', '',
'transition-colors cursor-pointer', 'transition-colors cursor-pointer',
className className
)} )}
title="Click to see AI suggestion" title="Click to see AI suggestion"
> >
<Sparkles className="h-3.5 w-3.5 text-purple-500" /> <Sparkles className="h-3.5 w-3.5 text-purple-500" />
<span className="text-purple-600 dark:text-purple-400 font-medium"> <span className="text-purple-600 font-medium">
{issues.length} {issues.length === 1 ? 'issue' : 'issues'} {issues.length} {issues.length === 1 ? 'issue' : 'issues'}
</span> </span>
<ChevronDown className="h-3 w-3 text-purple-400" /> <ChevronDown className="h-3 w-3 text-purple-400" />
@@ -222,7 +222,7 @@ export function AiSuggestionBadge({
className={cn( className={cn(
'flex flex-col gap-2 p-3 rounded-md', 'flex flex-col gap-2 p-3 rounded-md',
'bg-purple-50 border border-purple-200', 'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800', '',
className className
)} )}
> >
@@ -230,11 +230,11 @@ export function AiSuggestionBadge({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" /> <Sparkles className="h-3.5 w-3.5 text-purple-500 flex-shrink-0" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400"> <span className="text-xs font-medium text-purple-600">
AI Suggestion AI Suggestion
</span> </span>
{issues.length > 0 && ( {issues.length > 0 && (
<span className="text-xs text-purple-500 dark:text-purple-400"> <span className="text-xs text-purple-500">
({issues.length} {issues.length === 1 ? 'issue' : 'issues'}) ({issues.length} {issues.length === 1 ? 'issue' : 'issues'})
</span> </span>
)} )}
@@ -261,7 +261,7 @@ export function AiSuggestionBadge({
{issues.map((issue, index) => ( {issues.map((issue, index) => (
<div <div
key={index} key={index}
className="flex items-start gap-1.5 text-purple-600 dark:text-purple-400" className="flex items-start gap-1.5 text-purple-600"
> >
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" /> <AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0 text-purple-400" />
<span>{issue}</span> <span>{issue}</span>
@@ -272,10 +272,10 @@ export function AiSuggestionBadge({
{/* Suggested description */} {/* Suggested description */}
<div className="mt-1"> <div className="mt-1">
<div className="text-xs text-purple-500 dark:text-purple-400 mb-1 font-medium"> <div className="text-xs text-purple-500 mb-1 font-medium">
Suggested: Suggested:
</div> </div>
<div className="text-sm text-purple-700 dark:text-purple-300 leading-relaxed bg-white/50 dark:bg-black/20 rounded p-2 border border-purple-100 dark:border-purple-800"> <div className="text-sm text-purple-700 leading-relaxed bg-white/50 rounded p-2 border border-purple-100">
{suggestion} {suggestion}
</div> </div>
</div> </div>
@@ -285,7 +285,7 @@ export function AiSuggestionBadge({
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400 dark:bg-green-950/30 dark:border-green-700 dark:text-green-400" className="h-7 px-3 text-xs bg-white border-green-300 text-green-700 hover:bg-green-50 hover:border-green-400"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onAccept(suggestion); onAccept(suggestion);
@@ -297,7 +297,7 @@ export function AiSuggestionBadge({
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
className="h-7 px-3 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400" className="h-7 px-3 text-xs text-muted-foreground hover:text-foreground"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onDismiss(); onDismiss();
@@ -319,12 +319,12 @@ export function AiValidationLoading({ className }: { className?: string }) {
className={cn( className={cn(
'flex items-center gap-2 px-2 py-1 rounded-md text-xs', 'flex items-center gap-2 px-2 py-1 rounded-md text-xs',
'bg-purple-50 border border-purple-200', 'bg-purple-50 border border-purple-200',
'dark:bg-purple-950/30 dark:border-purple-800', '',
className className
)} )}
> >
<div className="h-3 w-3 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" /> <div className="h-3 w-3 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
<span className="text-purple-600 dark:text-purple-400"> <span className="text-purple-600">
Validating with AI... Validating with AI...
</span> </span>
</div> </div>
@@ -114,21 +114,21 @@ export const CopyDownBanner = memo(() => {
}} }}
> >
<div ref={bannerRef} className="pointer-events-auto"> <div ref={bannerRef} className="pointer-events-auto">
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200"> <div className="bg-blue-50 border border-blue-200 rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" /> <div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
<span className="text-xs font-medium text-blue-700 dark:text-blue-300 whitespace-nowrap"> <span className="text-xs font-medium text-blue-700 whitespace-nowrap">
Click row to copy to Click row to copy to
</span> </span>
{rowsBelow > 0 && ( {rowsBelow > 0 && (
<> <>
<div className="h-4 w-px bg-blue-200 dark:bg-blue-800" /> <div className="h-4 w-px bg-blue-200" />
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleApplyToAll} onClick={handleApplyToAll}
className="h-6 px-2 text-xs font-medium text-blue-700 hover:text-blue-900 hover:bg-blue-100 dark:text-blue-300 dark:hover:bg-blue-900 dark:hover:text-blue-100" className="h-6 px-2 text-xs font-medium text-blue-700 hover:text-blue-900 hover:bg-blue-100"
> >
<ArrowDownToLine className="h-3 w-3 mr-1" /> <ArrowDownToLine className="h-3 w-3 mr-1" />
All All
@@ -149,7 +149,7 @@ export const CopyDownBanner = memo(() => {
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleCancel} onClick={handleCancel}
className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100 dark:hover:bg-blue-900" className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100"
> >
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</Button> </Button>
@@ -62,7 +62,7 @@ export function SuggestionBadges({
<div className={cn('flex flex-wrap items-center gap-1.5', className)}> <div className={cn('flex flex-wrap items-center gap-1.5', className)}>
{/* Label */} {/* Label */}
<div className={cn( <div className={cn(
'flex items-center gap-1 text-purple-600 dark:text-purple-400', 'flex items-center gap-1 text-purple-600',
compact ? 'text-[10px]' : 'text-xs' compact ? 'text-[10px]' : 'text-xs'
)}> )}>
<Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} /> <Sparkles className={compact ? 'h-2.5 w-2.5' : 'h-3 w-3'} />
@@ -71,7 +71,7 @@ export function SuggestionBadges({
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="flex items-center gap-1 text-gray-400"> <div className="flex items-center gap-1 text-muted-foreground/70">
<Loader2 className={cn('animate-spin', compact ? 'h-2.5 w-2.5' : 'h-3 w-3')} /> <Loader2 className={cn('animate-spin', compact ? 'h-2.5 w-2.5' : 'h-3 w-3')} />
{!compact && <span className="text-xs">Loading...</span>} {!compact && <span className="text-xs">Loading...</span>}
</div> </div>
@@ -92,8 +92,8 @@ export function SuggestionBadges({
'inline-flex items-center gap-1 rounded-full border transition-colors', 'inline-flex items-center gap-1 rounded-full border transition-colors',
compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs', compact ? 'px-1.5 py-0.5 text-[10px]' : 'px-2 py-0.5 text-xs',
selected selected
? 'border-green-300 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-950 dark:text-green-400' ? 'border-green-300 bg-green-50 text-green-700'
: 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100 dark:border-purple-800 dark:bg-purple-950/50 dark:text-purple-300 dark:hover:bg-purple-900/50' : 'border-purple-200 bg-purple-50 text-purple-700 hover:bg-purple-100'
)} )}
title={suggestion.fullPath || suggestion.name} title={suggestion.fullPath || suggestion.name}
> >
@@ -141,8 +141,8 @@ export function InlineSuggestion({
className={cn( className={cn(
'flex items-center justify-between px-2 py-1.5 cursor-pointer', 'flex items-center justify-between px-2 py-1.5 cursor-pointer',
isSelected isSelected
? 'bg-green-50 dark:bg-green-950/30' ? 'bg-green-50'
: 'bg-purple-50/50 hover:bg-purple-100/50 dark:bg-purple-950/20 dark:hover:bg-purple-900/30' : 'bg-purple-50/50 hover:bg-purple-100/50'
)} )}
onClick={onSelect} onClick={onSelect}
> >
@@ -154,7 +154,7 @@ export function InlineSuggestion({
</div> </div>
<div className="flex items-center gap-2 flex-shrink-0 ml-2"> <div className="flex items-center gap-2 flex-shrink-0 ml-2">
{showScore && ( {showScore && (
<span className="text-xs text-purple-500 dark:text-purple-400"> <span className="text-xs text-purple-500">
{similarityPercent}% {similarityPercent}%
</span> </span>
)} )}
@@ -178,12 +178,12 @@ interface SuggestionSectionHeaderProps {
export function SuggestionSectionHeader({ isLoading, count }: SuggestionSectionHeaderProps) { export function SuggestionSectionHeader({ isLoading, count }: SuggestionSectionHeaderProps) {
return ( return (
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900"> <div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 bg-purple-50/80 border-b border-purple-100">
<Sparkles className="h-3 w-3" /> <Sparkles className="h-3 w-3" />
<span>AI Suggested</span> <span>AI Suggested</span>
{isLoading && <Loader2 className="h-3 w-3 animate-spin" />} {isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
{!isLoading && count !== undefined && ( {!isLoading && count !== undefined && (
<span className="text-purple-400 dark:text-purple-500">({count})</span> <span className="text-purple-400">({count})</span>
)} )}
</div> </div>
); );
@@ -871,8 +871,8 @@ const CellWrapper = memo(({
const cellHighlightClass = cn( const cellHighlightClass = cn(
'relative w-full group', 'relative w-full group',
isCopyDownSource && 'ring-2 ring-blue-500 ring-inset rounded', isCopyDownSource && 'ring-2 ring-blue-500 ring-inset rounded',
isInCopyDownRange && 'bg-blue-100 dark:bg-blue-900/30', isInCopyDownRange && 'bg-blue-100',
isCopyDownTarget && !isInCopyDownRange && 'hover:bg-blue-50 dark:hover:bg-blue-900/20 cursor-pointer' isCopyDownTarget && !isInCopyDownRange && 'hover:bg-blue-50 cursor-pointer'
); );
// When in copy-down mode for this field, make cell non-interactive so clicks go to parent // When in copy-down mode for this field, make cell non-interactive so clicks go to parent
@@ -933,7 +933,7 @@ const CellWrapper = memo(({
className={cn( className={cn(
'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full', 'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full',
'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600', 'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
'dark:bg-blue-900/50 dark:hover:bg-blue-900 dark:text-blue-400', '',
'shadow-sm', 'shadow-sm',
// Position further left if there are errors to avoid overlap // Position further left if there are errors to avoid overlap
hasErrors ? 'right-7' : 'right-0.5' hasErrors ? 'right-7' : 'right-0.5'
@@ -1420,7 +1420,7 @@ const VirtualRow = memo(({
// Use box-shadow for bottom border - renders more consistently with transforms than border-b // Use box-shadow for bottom border - renders more consistently with transforms than border-b
'shadow-[inset_0_-1px_0_0_hsl(var(--border))]', 'shadow-[inset_0_-1px_0_0_hsl(var(--border))]',
hasErrors && 'bg-destructive/5', hasErrors && 'bg-destructive/5',
isSelected && 'bg-blue-100 dark:bg-blue-900/40' isSelected && 'bg-blue-100'
)} )}
style={{ style={{
height: ROW_HEIGHT, height: ROW_HEIGHT,
@@ -1534,7 +1534,7 @@ const VirtualRow = memo(({
// Selection (blue) takes priority over errors (red) // Selection (blue) takes priority over errors (red)
shouldBeSticky && ( shouldBeSticky && (
isSelected isSelected
? "lg:[background:#dbeafe] lg:dark:[background:hsl(221_83%_25%/0.4)]" ? "lg:[background:#dbeafe] lg:(221_83%_25%/0.4)]"
: hasErrors : hasErrors
? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]" ? "lg:[background:linear-gradient(hsl(var(--destructive)/0.05),hsl(var(--destructive)/0.05)),hsl(var(--background))]"
: "lg:[background:hsl(var(--background))]" : "lg:[background:hsl(var(--background))]"
@@ -271,7 +271,7 @@ const MultiSelectCellComponent = ({
{/* Selected items section - floats to top of dropdown */} {/* Selected items section - floats to top of dropdown */}
{selectedValues.length > 0 && ( {selectedValues.length > 0 && (
<CommandGroup> <CommandGroup>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 dark:text-green-400 bg-green-50/80 dark:bg-green-950/40 border-b border-green-100 dark:border-green-900"> <div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-green-600 bg-green-50/80 border-b border-green-100">
<Check className="h-3 w-3" /> <Check className="h-3 w-3" />
<span>Selected ({selectedValues.length})</span> <span>Selected ({selectedValues.length})</span>
</div> </div>
@@ -286,7 +286,7 @@ const MultiSelectCellComponent = ({
key={`selected-${selectedVal}`} key={`selected-${selectedVal}`}
value={`selected-${label}`} value={`selected-${label}`}
onSelect={() => handleSelect(selectedVal)} onSelect={() => handleSelect(selectedVal)}
className="bg-green-50/50 dark:bg-green-950/30" className="bg-green-50/50"
> >
<Check className="mr-2 h-4 w-4 opacity-100 text-green-600" /> <Check className="mr-2 h-4 w-4 opacity-100 text-green-600" />
{field.key === 'colors' && hexColor && ( {field.key === 'colors' && hexColor && (
@@ -308,7 +308,7 @@ const MultiSelectCellComponent = ({
{/* AI Suggestions section - shown below selected items */} {/* AI Suggestions section - shown below selected items */}
{supportsSuggestions && (fieldSuggestions.length > 0 || suggestions.isLoading) && ( {supportsSuggestions && (fieldSuggestions.length > 0 || suggestions.isLoading) && (
<CommandGroup> <CommandGroup>
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 dark:text-purple-400 bg-purple-50/80 dark:bg-purple-950/40 border-b border-purple-100 dark:border-purple-900"> <div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-purple-600 bg-purple-50/80 border-b border-purple-100">
<Sparkles className="h-3 w-3" /> <Sparkles className="h-3 w-3" />
<span>Suggested</span> <span>Suggested</span>
{suggestions.isLoading && <Loader2 className="h-3 w-3 animate-spin" />} {suggestions.isLoading && <Loader2 className="h-3 w-3 animate-spin" />}
@@ -328,7 +328,7 @@ const MultiSelectCellComponent = ({
key={`suggestion-${suggestion.id}`} key={`suggestion-${suggestion.id}`}
value={`suggestion-${suggestion.name}`} value={`suggestion-${suggestion.name}`}
onSelect={() => handleSelect(String(suggestion.id))} onSelect={() => handleSelect(String(suggestion.id))}
className="bg-purple-50/30 dark:bg-purple-950/20" className="bg-purple-50/30"
> >
<div className="flex items-center gap-2 min-w-0 flex-1"> <div className="flex items-center gap-2 min-w-0 flex-1">
<Check className="h-4 w-4 flex-shrink-0 opacity-0" /> <Check className="h-4 w-4 flex-shrink-0 opacity-0" />
@@ -347,7 +347,7 @@ const MultiSelectCellComponent = ({
{field.key === 'colors' ? suggestion.name : (suggestion.fullPath || suggestion.name)} {field.key === 'colors' ? suggestion.name : (suggestion.fullPath || suggestion.name)}
</span> </span>
</div> </div>
<span className="text-xs text-purple-500 dark:text-purple-400 ml-2 flex-shrink-0"> <span className="text-xs text-purple-500 ml-2 flex-shrink-0">
{similarityPercent}% {similarityPercent}%
</span> </span>
</CommandItem> </CommandItem>
@@ -313,7 +313,7 @@ const MultilineInputComponent = ({
'overflow-hidden leading-tight h-[65px] top-0', 'overflow-hidden leading-tight h-[65px] top-0',
'border', 'border',
hasError ? 'border-destructive bg-destructive/5' : 'border-input', hasError ? 'border-destructive bg-destructive/5' : 'border-input',
hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50 dark:border-purple-700 dark:bg-purple-950/20', hasAiSuggestion && !hasError && 'border-purple-300 bg-purple-50/50',
isValidating && 'opacity-50' isValidating && 'opacity-50'
)} )}
> >
@@ -335,7 +335,7 @@ const MultilineInputComponent = ({
setEditValue(initValue); setEditValue(initValue);
initialEditValueRef.current = initValue; initialEditValueRef.current = initValue;
}} }}
className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 dark:bg-purple-900/50 dark:hover:bg-purple-800/50 text-purple-600 dark:text-purple-400 text-xs transition-colors" className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 hover:bg-purple-200 text-purple-600 text-xs transition-colors"
title="View AI suggestion" title="View AI suggestion"
> >
<Sparkles className="h-3 w-3" /> <Sparkles className="h-3 w-3" />
@@ -344,7 +344,7 @@ const MultilineInputComponent = ({
)} )}
{/* AI validating indicator */} {/* AI validating indicator */}
{isAiValidating && ( {isAiValidating && (
<div className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100 dark:bg-purple-900/50"> <div className="absolute bottom-1 right-1 flex items-center gap-1 px-1.5 py-0.5 rounded bg-purple-100">
<Loader2 className="h-3 w-3 animate-spin text-purple-500" /> <Loader2 className="h-3 w-3 animate-spin text-purple-500" />
</div> </div>
)} )}
@@ -67,7 +67,7 @@ const DiffDisplay = ({ original, corrected }: { original: unknown; corrected: un
return ( return (
<span <span
key={index} key={index}
className="bg-green-100 dark:bg-green-900/50 text-green-800 dark:text-green-200 rounded px-0.5" className="bg-green-100 text-green-800 rounded px-0.5"
> >
{part.value} {part.value}
</span> </span>
@@ -77,7 +77,7 @@ const DiffDisplay = ({ original, corrected }: { original: unknown; corrected: un
return ( return (
<span <span
key={index} key={index}
className="bg-red-100 dark:bg-red-900/50 text-red-800 dark:text-red-200 line-through rounded px-0.5" className="bg-red-100 text-red-800 line-through rounded px-0.5"
> >
{part.value} {part.value}
</span> </span>
@@ -153,22 +153,22 @@ const SummaryDisplay = ({
<div className="space-y-2"> <div className="space-y-2">
{/* Summary */} {/* Summary */}
{summary && ( {summary && (
<div className="p-3 rounded-lg bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800"> <div className="p-3 rounded-lg bg-blue-50 border border-blue-200">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<Info className="h-4 w-4 text-blue-600 dark:text-blue-400 mt-0.5 flex-shrink-0" /> <Info className="h-4 w-4 text-blue-600 mt-0.5 flex-shrink-0" />
<div className="text-sm text-blue-800 dark:text-blue-200">{summary}</div> <div className="text-sm text-blue-800">{summary}</div>
</div> </div>
</div> </div>
)} )}
{/* Warnings */} {/* Warnings */}
{warnings && warnings.length > 0 && ( {warnings && warnings.length > 0 && (
<div className="p-3 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800"> <div className="p-3 rounded-lg bg-amber-50 border border-amber-200">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400 mt-0.5 flex-shrink-0" /> <AlertTriangle className="h-4 w-4 text-amber-600 mt-0.5 flex-shrink-0" />
<div className="space-y-1"> <div className="space-y-1">
{warnings.map((warning, index) => ( {warnings.map((warning, index) => (
<div key={index} className="text-sm text-amber-800 dark:text-amber-200"> <div key={index} className="text-sm text-amber-800">
{warning} {warning}
</div> </div>
))} ))}
@@ -205,7 +205,7 @@ export function SanityCheckDialog({
{Object.entries(groups).map(([key, group]) => ( {Object.entries(groups).map(([key, group]) => (
<div <div
key={key} key={key}
className="flex items-start gap-3 p-3 rounded-lg bg-gray-50 border border-gray-200 hover:border-gray-300 transition-colors" className="flex items-start gap-3 p-3 rounded-lg bg-surface-subtle border border-border hover:border-muted-foreground/30 transition-colors"
> >
<AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" /> <AlertCircle className="h-4 w-4 text-amber-500 flex-shrink-0 mt-0.5" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -234,12 +234,12 @@ export function SanityCheckDialog({
</Button> </Button>
)} )}
{idx < group.productIndices.length - 1 && ( {idx < group.productIndices.length - 1 && (
<span className="text-gray-400 mr-1">,</span> <span className="text-muted-foreground/70 mr-1">,</span>
)} )}
</span> </span>
))} ))}
</div> </div>
<p className="text-sm text-gray-600">{group.issue}</p> <p className="text-sm text-muted-foreground">{group.issue}</p>
{group.suggestion && ( {group.suggestion && (
<p className="text-xs text-blue-600 mt-1"> <p className="text-xs text-blue-600 mt-1">
{group.suggestion} {group.suggestion}
@@ -172,7 +172,7 @@ export function ProductDetail({ productId, onClose }: ProductDetailProps) {
<VaulDrawer.Overlay className="fixed inset-0 bg-black/40 z-40" /> <VaulDrawer.Overlay className="fixed inset-0 bg-black/40 z-40" />
<VaulDrawer.Content className="fixed right-0 top-0 h-dvh w-[90%] max-w-[800px] bg-background p-0 shadow-lg flex flex-col z-50"> <VaulDrawer.Content className="fixed right-0 top-0 h-dvh w-[90%] max-w-[800px] bg-background p-0 shadow-lg flex flex-col z-50">
{/* Header */} {/* Header */}
<div className="flex items-start justify-between p-4 border-b sticky top-0 bg-background z-10"> <div className="flex items-start justify-between p-4 border-b sticky top-0 bg-card z-10">
<div className="flex items-center gap-4 overflow-hidden"> <div className="flex items-center gap-4 overflow-hidden">
{isLoadingProduct ? ( {isLoadingProduct ? (
<Skeleton className="h-16 w-16 rounded-lg" /> <Skeleton className="h-16 w-16 rounded-lg" />
@@ -198,15 +198,15 @@ export function ProductTable({
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<div className="border rounded-md relative"> <div className="border rounded-xl bg-card relative overflow-hidden">
{isLoading && ( {isLoading && (
<div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20"> <div className="absolute inset-0 bg-card/70 flex items-center justify-center z-20">
<Skeleton className="h-8 w-32" /> <Skeleton className="h-8 w-32" />
</div> </div>
)} )}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table className={cn(isLoading ? 'opacity-50' : '', "w-max min-w-full")}> <Table className={cn(isLoading ? 'opacity-50' : '', "w-max min-w-full")}>
<TableHeader className="sticky top-0 bg-background z-10"> <TableHeader className="sticky top-0 bg-card z-10">
<TableRow> <TableRow>
<SortableContext <SortableContext
items={orderedVisibleColumns} items={orderedVisibleColumns}
@@ -86,7 +86,7 @@ export default function FilterControls({
placeholder="Search orders..." placeholder="Search orders..."
value={searchInput} value={searchInput}
onChange={(e) => setSearchInput(e.target.value)} onChange={(e) => setSearchInput(e.target.value)}
className="max-w-xs" className="max-w-xs rounded-full px-3.5"
disabled={loading} disabled={loading}
/> />
<Select <Select
@@ -106,11 +106,11 @@ export default function PipelineCard() {
</div> </div>
</div> </div>
{data.overdue.count > 0 ? ( {data.overdue.count > 0 ? (
<div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50/50 dark:border-red-900/50 dark:bg-red-950/20 p-2.5"> <div className="flex items-center gap-2 rounded-lg border border-red-200 bg-red-50/50 p-2.5">
<AlertTriangle className="h-4 w-4 text-red-500" /> <AlertTriangle className="h-4 w-4 text-red-500" />
<div> <div>
<p className="text-xs text-red-600 dark:text-red-400">Overdue</p> <p className="text-xs text-red-600">Overdue</p>
<p className="text-sm font-bold text-red-600 dark:text-red-400"> <p className="text-sm font-bold text-red-600">
{data.overdue.count} POs ({formatCurrency(data.overdue.value)}) {data.overdue.count} POs ({formatCurrency(data.overdue.value)})
</p> </p>
</div> </div>
@@ -60,7 +60,7 @@ export default function PurchaseOrderAccordion({
// Clone the TableRow (children) and add the onClick handler and className // Clone the TableRow (children) and add the onClick handler and className
const enhancedRow = React.cloneElement(children as React.ReactElement, { const enhancedRow = React.cloneElement(children as React.ReactElement, {
onClick: () => setIsOpen(!isOpen), onClick: () => setIsOpen(!isOpen),
className: `${(children as React.ReactElement).props.className || ""} cursor-pointer ${isOpen ? 'bg-gray-100' : ''} ${rowClassName || ""}`.trim(), className: `${(children as React.ReactElement).props.className || ""} cursor-pointer ${isOpen ? "bg-muted" : ""} ${rowClassName || ""}`.trim(),
"data-state": isOpen ? "open" : "closed" "data-state": isOpen ? "open" : "closed"
}); });
@@ -116,7 +116,7 @@ export default function PurchaseOrderAccordion({
} }
return ( return (
<div className="max-h-[600px] overflow-y-auto bg-gray-50 rounded-md p-2"> <div className="max-h-[600px] overflow-y-auto bg-surface-subtle rounded-md p-2">
<Table className="w-full"> <Table className="w-full">
<TableHeader className="bg-white sticky top-0 z-10"> <TableHeader className="bg-white sticky top-0 z-10">
<TableRow> <TableRow>
@@ -151,7 +151,7 @@ export default function PurchaseOrderAccordion({
)) ))
) : ( ) : (
items.map((item) => ( items.map((item) => (
<TableRow key={item.id} className="hover:bg-gray-100"> <TableRow key={item.id} className="hover:bg-muted">
<TableCell className=""> <TableCell className="">
<a <a
href={`https://backend.acherryontop.com/product/${item.pid}`} href={`https://backend.acherryontop.com/product/${item.pid}`}
@@ -228,7 +228,7 @@ export default function PurchaseOrderAccordion({
{isOpen && ( {isOpen && (
<TableRow className="p-0 border-0"> <TableRow className="p-0 border-0">
<TableCell colSpan={12} className="p-0 border-0"> <TableCell colSpan={12} className="p-0 border-0">
<div className="pt-2 pb-4 px-4 bg-gray-50 border-t border-b"> <div className="pt-2 pb-4 px-4 bg-surface-subtle border-t border-b">
<div className="mb-2 text-sm text-muted-foreground"> <div className="mb-2 text-sm text-muted-foreground">
{purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"} {purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"}
</div> </div>
@@ -96,7 +96,7 @@ export default function PurchaseOrdersTable({
return ( return (
<Badge <Badge
variant="outline" variant="outline"
className="flex items-center justify-center border-gray-500 text-gray-700 bg-gray-50 px-0 text-xs w-[85px]" className="flex items-center justify-center border-muted-foreground/40 text-muted-foreground bg-surface-subtle px-0 text-xs w-[85px]"
> >
{recordType || "Unknown"} {recordType || "Unknown"}
</Badge> </Badge>
@@ -323,7 +323,7 @@ export default function PurchaseOrdersTable({
) : purchaseOrders.length > 0 ? ( ) : purchaseOrders.length > 0 ? (
purchaseOrders.map((po) => { purchaseOrders.map((po) => {
// Determine row styling based on record type // Determine row styling based on record type
let rowClassName = "border-l-4 border-l-gray-300"; // Default let rowClassName = "border-l-4 border-l-[#d8d4cd]"; // Default
if (po.record_type === "po_with_receiving") { if (po.record_type === "po_with_receiving") {
rowClassName = "border-l-4 border-l-green-500"; rowClassName = "border-l-4 border-l-green-500";
+12 -12
View File
@@ -307,11 +307,11 @@ function Filters({
function SuccessBadge({ success }: { success: boolean }) { function SuccessBadge({ success }: { success: boolean }) {
return success ? ( return success ? (
<Badge variant="outline" className="text-green-600 border-green-300 bg-green-50 dark:bg-green-950 dark:border-green-800 dark:text-green-400"> <Badge variant="outline" className="text-green-600 border-green-300 bg-green-50">
Success Success
</Badge> </Badge>
) : ( ) : (
<Badge variant="outline" className="text-red-600 border-red-300 bg-red-50 dark:bg-red-950 dark:border-red-800 dark:text-red-400"> <Badge variant="outline" className="text-red-600 border-red-300 bg-red-50">
Failed Failed
</Badge> </Badge>
); );
@@ -342,7 +342,7 @@ function EditorTable({ entries, onView }: { entries: AuditEntry[]; onView: (id:
href={`https://backend.acherryontop.com/product/${e.pid}`} href={`https://backend.acherryontop.com/product/${e.pid}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-mono text-sm hover:underline text-blue-600 dark:text-blue-400" className="font-mono text-sm hover:underline text-blue-600"
> >
{e.pid} {e.pid}
</a> </a>
@@ -434,7 +434,7 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
href={`https://backend.acherryontop.com/product/${detail.pid}`} href={`https://backend.acherryontop.com/product/${detail.pid}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="font-mono hover:underline text-blue-600 dark:text-blue-400" className="font-mono hover:underline text-blue-600"
> >
{detail.pid} {detail.pid}
</a> </a>
@@ -478,7 +478,7 @@ function DetailView({ detail, logType }: { detail: AuditDetail; logType: LogType
{detail.error_message && ( {detail.error_message && (
<div> <div>
<span className="text-muted-foreground">Error</span> <span className="text-muted-foreground">Error</span>
<p className="text-red-600 dark:text-red-400 mt-1 text-xs bg-red-50 dark:bg-red-950 p-2 rounded-md break-all"> <p className="text-red-600 mt-1 text-xs bg-red-50 p-2 rounded-md break-all">
{detail.error_message} {detail.error_message}
</p> </p>
</div> </div>
@@ -576,11 +576,11 @@ function JsonValue({ value, depth }: { value: unknown; depth: number }) {
} }
if (typeof value === "boolean") { if (typeof value === "boolean") {
return <span className={value ? "text-green-600 dark:text-green-400" : "text-red-600 dark:text-red-400"}>{String(value)}</span>; return <span className={value ? "text-green-600" : "text-red-600"}>{String(value)}</span>;
} }
if (typeof value === "number") { if (typeof value === "number") {
return <span className="text-blue-600 dark:text-blue-400">{value}</span>; return <span className="text-blue-600">{value}</span>;
} }
if (typeof value === "string") { if (typeof value === "string") {
@@ -605,7 +605,7 @@ function JsonString({ value }: { value: string }) {
if (value.length <= STRING_TRUNCATE_LIMIT || expanded) { if (value.length <= STRING_TRUNCATE_LIMIT || expanded) {
return ( return (
<span className="text-amber-700 dark:text-amber-400 break-words whitespace-pre-wrap"> <span className="text-amber-700 break-words whitespace-pre-wrap">
{value} {value}
{expanded && value.length > STRING_TRUNCATE_LIMIT && ( {expanded && value.length > STRING_TRUNCATE_LIMIT && (
<button onClick={() => setExpanded(false)} className="ml-1 text-muted-foreground hover:text-foreground text-[10px] underline"> <button onClick={() => setExpanded(false)} className="ml-1 text-muted-foreground hover:text-foreground text-[10px] underline">
@@ -617,7 +617,7 @@ function JsonString({ value }: { value: string }) {
} }
return ( return (
<span className="text-amber-700 dark:text-amber-400 break-words whitespace-pre-wrap"> <span className="text-amber-700 break-words whitespace-pre-wrap">
{value.slice(0, STRING_TRUNCATE_LIMIT)} {value.slice(0, STRING_TRUNCATE_LIMIT)}
<span className="text-muted-foreground">... </span> <span className="text-muted-foreground">... </span>
<button onClick={() => setExpanded(true)} className="text-muted-foreground hover:text-foreground text-[10px] underline"> <button onClick={() => setExpanded(true)} className="text-muted-foreground hover:text-foreground text-[10px] underline">
@@ -642,7 +642,7 @@ function JsonObject({ obj, depth }: { obj: Record<string, unknown>; depth: numbe
{"{ "} {"{ "}
{entries.map(([k, v], i) => ( {entries.map(([k, v], i) => (
<span key={k}> <span key={k}>
<span className="text-purple-600 dark:text-purple-400">{k}</span> <span className="text-purple-600">{k}</span>
<span className="text-muted-foreground">: </span> <span className="text-muted-foreground">: </span>
<JsonValue value={v} depth={depth + 1} /> <JsonValue value={v} depth={depth + 1} />
{i < entries.length - 1 && <span className="text-muted-foreground">, </span>} {i < entries.length - 1 && <span className="text-muted-foreground">, </span>}
@@ -676,7 +676,7 @@ function JsonField({ fieldKey, value, depth }: { fieldKey: string; value: unknow
className="inline-flex items-center gap-1 hover:text-foreground" className="inline-flex items-center gap-1 hover:text-foreground"
> >
{open ? <ChevronDown className="h-3 w-3 text-muted-foreground" /> : <ChevronRightIcon className="h-3 w-3 text-muted-foreground" />} {open ? <ChevronDown className="h-3 w-3 text-muted-foreground" /> : <ChevronRightIcon className="h-3 w-3 text-muted-foreground" />}
<span className="text-purple-600 dark:text-purple-400 font-medium">{fieldKey}</span> <span className="text-purple-600 font-medium">{fieldKey}</span>
</button> </button>
{open && ( {open && (
<div className="ml-4 border-l border-border pl-3 mt-1"> <div className="ml-4 border-l border-border pl-3 mt-1">
@@ -690,7 +690,7 @@ function JsonField({ fieldKey, value, depth }: { fieldKey: string; value: unknow
// Primitive or small array — render inline // Primitive or small array — render inline
return ( return (
<div> <div>
<span className="text-purple-600 dark:text-purple-400 font-medium">{fieldKey}</span> <span className="text-purple-600 font-medium">{fieldKey}</span>
<span className="text-muted-foreground">: </span> <span className="text-muted-foreground">: </span>
<JsonValue value={value} depth={depth + 1} /> <JsonValue value={value} depth={depth + 1} />
</div> </div>
@@ -401,12 +401,12 @@ export function DataManagement() {
if (data.completed_steps && Array.isArray(data.completed_steps)) { if (data.completed_steps && Array.isArray(data.completed_steps)) {
return ( return (
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
<div className="text-sm font-semibold text-gray-700">Completed Steps:</div> <div className="text-sm font-semibold text-foreground">Completed Steps:</div>
<div className="space-y-1 bg-gray-50 p-2 rounded"> <div className="space-y-1 bg-surface-subtle p-2 rounded">
{data.completed_steps.map((step: any, idx: number) => ( {data.completed_steps.map((step: any, idx: number) => (
<div key={idx} className="flex justify-between text-sm"> <div key={idx} className="flex justify-between text-sm">
<span className="font-medium">{step.name}</span> <span className="font-medium">{step.name}</span>
<div className="flex gap-4 text-gray-600"> <div className="flex gap-4 text-muted-foreground">
<span>{step.duration}s</span> <span>{step.duration}s</span>
{step.rowsAffected !== undefined && ( {step.rowsAffected !== undefined && (
<span>{formatNumber(step.rowsAffected)} rows</span> <span>{formatNumber(step.rowsAffected)} rows</span>
@@ -434,15 +434,15 @@ export function DataManagement() {
if (data.details) { if (data.details) {
return ( return (
<div className="space-y-2 mt-2"> <div className="space-y-2 mt-2">
<div className="text-sm font-semibold text-gray-700">Import Details:</div> <div className="text-sm font-semibold text-foreground">Import Details:</div>
<div className="space-y-2 bg-gray-50 p-2 rounded"> <div className="space-y-2 bg-surface-subtle p-2 rounded">
{Object.entries(data.details).map(([table, stats]: [string, any]) => ( {Object.entries(data.details).map(([table, stats]: [string, any]) => (
<div key={table} className="border-b last:border-0 pb-2 last:pb-0"> <div key={table} className="border-b last:border-0 pb-2 last:pb-0">
<div className="font-medium text-sm capitalize mb-1">{table}:</div> <div className="font-medium text-sm capitalize mb-1">{table}:</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs"> <div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
{Object.entries(stats).map(([key, value]) => ( {Object.entries(stats).map(([key, value]) => (
<div key={key} className="flex justify-between"> <div key={key} className="flex justify-between">
<span className="text-gray-600">{key}:</span> <span className="text-muted-foreground">{key}:</span>
<span className="font-mono">{typeof value === 'number' ? formatNumber(value) : String(value)}</span> <span className="font-mono">{typeof value === 'number' ? formatNumber(value) : String(value)}</span>
</div> </div>
))} ))}
@@ -453,11 +453,11 @@ export function DataManagement() {
{/* Show step timings if present */} {/* Show step timings if present */}
{data.step_timings && ( {data.step_timings && (
<div className="mt-2"> <div className="mt-2">
<div className="text-sm font-semibold text-gray-700">Step Timings:</div> <div className="text-sm font-semibold text-foreground">Step Timings:</div>
<div className="space-y-1 bg-gray-50 p-2 rounded text-sm"> <div className="space-y-1 bg-surface-subtle p-2 rounded text-sm">
{Object.entries(data.step_timings).map(([step, duration]) => ( {Object.entries(data.step_timings).map(([step, duration]) => (
<div key={step} className="flex justify-between"> <div key={step} className="flex justify-between">
<span className="text-gray-600">{step}:</span> <span className="text-muted-foreground">{step}:</span>
<span className="font-mono">{String(duration)}s</span> <span className="font-mono">{String(duration)}s</span>
</div> </div>
))} ))}
@@ -480,11 +480,11 @@ export function DataManagement() {
); );
return ( return (
<div className="space-y-1 mt-2 bg-gray-50 p-2 rounded text-sm font-mono"> <div className="space-y-1 mt-2 bg-surface-subtle p-2 rounded text-sm font-mono">
{Object.entries(data).map(([key, value]) => ( {Object.entries(data).map(([key, value]) => (
<div key={key} className="flex"> <div key={key} className="flex">
<span <span
className="text-gray-600 font-bold shrink-0" className="text-muted-foreground font-bold shrink-0"
style={{ width: `${maxKeyLength + 2}ch` }} style={{ width: `${maxKeyLength + 2}ch` }}
> >
{key}: {key}:
@@ -920,7 +920,7 @@ export function DataManagement() {
{table.error ? ( {table.error ? (
<span className="text-red-600 text-sm">{table.error}</span> <span className="text-red-600 text-sm">{table.error}</span>
) : ( ) : (
<span className="text-sm text-gray-600">{formatNumber(table.count)}</span> <span className="text-sm text-muted-foreground">{formatNumber(table.count)}</span>
)} )}
</div> </div>
))} ))}
@@ -1104,7 +1104,7 @@ export function DataManagement() {
className="flex justify-between text-sm items-center py-2 border-b last:border-0" className="flex justify-between text-sm items-center py-2 border-b last:border-0"
> >
<span className="font-medium">{table.table_name}</span> <span className="font-medium">{table.table_name}</span>
<span className="text-sm text-gray-600"> <span className="text-sm text-muted-foreground">
{formatStatusTime(table.last_sync_timestamp)} {formatStatusTime(table.last_sync_timestamp)}
</span> </span>
</div> </div>
@@ -1146,7 +1146,7 @@ export function DataManagement() {
className="flex justify-between text-sm items-center py-2 border-b last:border-0" className="flex justify-between text-sm items-center py-2 border-b last:border-0"
> >
<span className="font-medium">{module.module_name}</span> <span className="font-medium">{module.module_name}</span>
<span className="text-sm text-gray-600"> <span className="text-sm text-muted-foreground">
{formatStatusTime(module.last_calculation_timestamp)} {formatStatusTime(module.last_calculation_timestamp)}
</span> </span>
</div> </div>
@@ -1197,7 +1197,7 @@ export function DataManagement() {
</span> </span>
</div> </div>
<div className="w-[170px]"> <div className="w-[170px]">
<span className="text-sm text-gray-600"> <span className="text-sm text-muted-foreground">
{formatDate(record.start_time)} {formatDate(record.start_time)}
</span> </span>
</div> </div>
@@ -1230,7 +1230,7 @@ export function DataManagement() {
<AccordionContent className="px-4 pb-2"> <AccordionContent className="px-4 pb-2">
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-gray-600">End Time:</span> <span className="text-muted-foreground">End Time:</span>
<span> <span>
{record.end_time {record.end_time
? formatDate(record.end_time) ? formatDate(record.end_time)
@@ -1239,28 +1239,28 @@ export function DataManagement() {
</div> </div>
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Added:</span> <span className="text-muted-foreground">Added:</span>
<span className="text-green-600 font-medium">{formatNumber(record.records_added)}</span> <span className="text-green-600 font-medium">{formatNumber(record.records_added)}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Updated:</span> <span className="text-muted-foreground">Updated:</span>
<span className="text-blue-600 font-medium">{formatNumber(record.records_updated)}</span> <span className="text-blue-600 font-medium">{formatNumber(record.records_updated)}</span>
</div> </div>
{record.records_deleted !== undefined && ( {record.records_deleted !== undefined && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Deleted:</span> <span className="text-muted-foreground">Deleted:</span>
<span className="text-red-600 font-medium">{formatNumber(record.records_deleted)}</span> <span className="text-red-600 font-medium">{formatNumber(record.records_deleted)}</span>
</div> </div>
)} )}
{record.records_skipped !== undefined && ( {record.records_skipped !== undefined && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Skipped:</span> <span className="text-muted-foreground">Skipped:</span>
<span className="text-yellow-600 font-medium">{formatNumber(record.records_skipped)}</span> <span className="text-yellow-600 font-medium">{formatNumber(record.records_skipped)}</span>
</div> </div>
)} )}
{record.total_processed !== undefined && ( {record.total_processed !== undefined && (
<div className="flex justify-between col-span-2"> <div className="flex justify-between col-span-2">
<span className="text-gray-600">Total Processed:</span> <span className="text-muted-foreground">Total Processed:</span>
<span className="font-medium">{formatNumber(record.total_processed)}</span> <span className="font-medium">{formatNumber(record.total_processed)}</span>
</div> </div>
)} )}
@@ -1319,7 +1319,7 @@ export function DataManagement() {
</span> </span>
</div> </div>
<div className="w-[170px]"> <div className="w-[170px]">
<span className="text-sm text-gray-600"> <span className="text-sm text-muted-foreground">
{formatDate(record.start_time)} {formatDate(record.start_time)}
</span> </span>
</div> </div>
@@ -1352,7 +1352,7 @@ export function DataManagement() {
<AccordionContent className="px-4 pb-2"> <AccordionContent className="px-4 pb-2">
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-gray-600">End Time:</span> <span className="text-muted-foreground">End Time:</span>
<span> <span>
{record.end_time {record.end_time
? formatDate(record.end_time) ? formatDate(record.end_time)
@@ -1360,15 +1360,15 @@ export function DataManagement() {
</span> </span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-gray-600">Products:</span> <span className="text-muted-foreground">Products:</span>
<span>{record.processed_products} of {record.total_products}</span> <span>{record.processed_products} of {record.total_products}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-gray-600">Orders:</span> <span className="text-muted-foreground">Orders:</span>
<span>{record.processed_orders} of {record.total_orders}</span> <span>{record.processed_orders} of {record.total_orders}</span>
</div> </div>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-gray-600">Purchase Orders:</span> <span className="text-muted-foreground">Purchase Orders:</span>
<span>{record.processed_purchase_orders} of {record.total_purchase_orders}</span> <span>{record.processed_purchase_orders} of {record.total_purchase_orders}</span>
</div> </div>
{record.error_message && ( {record.error_message && (
@@ -89,21 +89,6 @@ export function GlobalSettings() {
</SelectContent> </SelectContent>
</Select> </Select>
); );
} else if (setting.setting_key === 'default_forecast_method') {
return (
<Select
value={setting.setting_value}
onValueChange={(value) => updateSetting(setting.setting_key, value)}
>
<SelectTrigger>
<SelectValue placeholder="Select forecast method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem value="seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
);
} else if (setting.setting_key.includes('threshold')) { } else if (setting.setting_key.includes('threshold')) {
// Percentage inputs // Percentage inputs
return ( return (
@@ -6,7 +6,6 @@ import { Input } from "@/components/ui/input";
import { toast } from "sonner"; import { toast } from "sonner";
import config from '../../config'; import config from '../../config';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Search } from 'lucide-react'; import { Search } from 'lucide-react';
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
@@ -25,7 +24,6 @@ interface ProductSetting {
lead_time_days: number | null; lead_time_days: number | null;
days_of_stock: number | null; days_of_stock: number | null;
safety_stock: number; safety_stock: number;
forecast_method: string | null;
exclude_from_forecast: boolean; exclude_from_forecast: boolean;
updated_at: string; updated_at: string;
product_name?: string; // Added for display purposes product_name?: string; // Added for display purposes
@@ -86,7 +84,6 @@ export function ProductSettings() {
lead_time_days: setting.lead_time_days, lead_time_days: setting.lead_time_days,
days_of_stock: setting.days_of_stock, days_of_stock: setting.days_of_stock,
safety_stock: setting.safety_stock, safety_stock: setting.safety_stock,
forecast_method: setting.forecast_method,
exclude_from_forecast: setting.exclude_from_forecast exclude_from_forecast: setting.exclude_from_forecast
}) })
}); });
@@ -180,7 +177,7 @@ export function ProductSettings() {
<Input <Input
type="search" type="search"
placeholder="Search products by ID or name..." placeholder="Search products by ID or name..."
className="pl-8" className="pl-8 rounded-full"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
/> />
@@ -195,7 +192,6 @@ export function ProductSettings() {
<TableHead>Lead Time (days)</TableHead> <TableHead>Lead Time (days)</TableHead>
<TableHead>Days of Stock</TableHead> <TableHead>Days of Stock</TableHead>
<TableHead>Safety Stock</TableHead> <TableHead>Safety Stock</TableHead>
<TableHead>Forecast Method</TableHead>
<TableHead>Exclude</TableHead> <TableHead>Exclude</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
@@ -231,21 +227,6 @@ export function ProductSettings() {
onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)} onChange={(e) => updateSetting(setting.pid, 'safety_stock', parseInt(e.target.value) || 0)}
/> />
</TableCell> </TableCell>
<TableCell>
<Select
value={setting.forecast_method || 'default'}
onValueChange={(value) => updateSetting(setting.pid, 'forecast_method', value === 'default' ? null : value)}
>
<SelectTrigger className="w-28">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
<SelectItem value="seasonal">Seasonal</SelectItem>
</SelectContent>
</Select>
</TableCell>
<TableCell> <TableCell>
<Switch <Switch
checked={setting.exclude_from_forecast} checked={setting.exclude_from_forecast}
@@ -543,7 +543,7 @@ export function PromptManagement() {
placeholder="Search prompts..." placeholder="Search prompts..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-sm" className="max-w-sm rounded-full px-3.5"
/> />
</div> </div>
@@ -568,7 +568,7 @@ export function PromptManagement() {
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-100"> <TableRow key={row.id} className="hover:bg-muted">
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="pl-3 whitespace-nowrap"> <TableCell key={cell.id} className="pl-3 whitespace-nowrap">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -593,7 +593,7 @@ export function ReusableImageManagement() {
placeholder="Search images..." placeholder="Search images..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-sm" className="max-w-sm rounded-full px-3.5"
/> />
</div> </div>
@@ -621,7 +621,7 @@ export function ReusableImageManagement() {
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-100"> <TableRow key={row.id} className="hover:bg-muted">
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="pl-6"> <TableCell key={cell.id} className="pl-6">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -276,7 +276,7 @@ export function TemplateManagement() {
placeholder="Search templates..." placeholder="Search templates..."
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="max-w-sm" className="max-w-sm rounded-full px-3.5"
/> />
</div> </div>
@@ -304,7 +304,7 @@ export function TemplateManagement() {
<TableBody> <TableBody>
{table.getRowModel().rows?.length ? ( {table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => ( table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="hover:bg-gray-100"> <TableRow key={row.id} className="hover:bg-muted">
{row.getVisibleCells().map((cell) => ( {row.getVisibleCells().map((cell) => (
<TableCell key={cell.id} className="pl-6"> <TableCell key={cell.id} className="pl-6">
{flexRender(cell.column.columnDef.cell, cell.getContext())} {flexRender(cell.column.columnDef.cell, cell.getContext())}
@@ -58,7 +58,7 @@ export function UserList({ users, onEdit, onDelete }: UserListProps) {
{user.is_active ? ( {user.is_active ? (
<Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge> <Badge variant="default" className="bg-green-100 text-green-800 hover:bg-green-100">Active</Badge>
) : ( ) : (
<Badge variant="secondary" className="bg-slate-100">Inactive</Badge> <Badge variant="secondary" className="bg-muted">Inactive</Badge>
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -172,7 +172,7 @@ export function VendorSettings() {
<Input <Input
type="search" type="search"
placeholder="Search vendors..." placeholder="Search vendors..."
className="pl-8" className="pl-8 rounded-full"
value={searchInputValue} value={searchInputValue}
onChange={(e) => setSearchInputValue(e.target.value)} onChange={(e) => setSearchInputValue(e.target.value)}
/> />
+82
View File
@@ -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>
<div className="relative"> <div className="relative">
<Table> <Table>
<TableHeader className="sticky top-0 bg-background z-10 border-b"> <TableHeader className="sticky top-0 bg-card z-10 border-b">
<TableRow> <TableRow>
<SortableTableHead field="title">Name</SortableTableHead> <SortableTableHead field="title">Name</SortableTableHead>
{searchParams.company === 'all' && ( {searchParams.company === 'all' && (
+1 -1
View File
@@ -34,7 +34,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
+1 -1
View File
@@ -10,7 +10,7 @@ const alertVariants = cva(
variant: { variant: {
default: "bg-background text-foreground", default: "bg-background text-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "border-destructive/50 text-destructive [&>svg]:text-destructive",
}, },
}, },
defaultVariants: { defaultVariants: {
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( 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: { variants: {
variant: { variant: {
+4 -4
View File
@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const buttonVariants = cva( 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: { variants: {
variant: { variant: {
@@ -14,7 +14,7 @@ const buttonVariants = cva(
destructive: destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline: 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: secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground", ghost: "hover:bg-accent hover:text-accent-foreground",
@@ -22,8 +22,8 @@ const buttonVariants = cva(
}, },
size: { size: {
default: "h-9 px-4 py-2", default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs", sm: "h-8 px-3 text-xs",
lg: "h-10 rounded-md px-8", lg: "h-10 px-8",
icon: "h-9 w-9", icon: "h-9 w-9",
}, },
}, },
+2 -2
View File
@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -36,7 +36,7 @@ const CardTitle = React.forwardRef<
<h3 <h3
ref={ref} ref={ref}
className={cn( className={cn(
"text-2xl font-semibold leading-none tracking-tight", "text-base font-semibold leading-none tracking-tight",
className className
)} )}
{...props} {...props}
+1 -1
View File
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
+1 -1
View File
@@ -43,7 +43,7 @@ const DrawerContent = React.forwardRef<
<DrawerPrimitive.Content <DrawerPrimitive.Content
ref={ref} ref={ref}
className={cn( 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-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", "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-xl": side === "right",
+1 -1
View File
@@ -17,7 +17,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
+1 -1
View File
@@ -31,7 +31,7 @@ const SheetOverlay = React.forwardRef<
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
const sheetVariants = cva( 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: { variants: {
side: { side: {
+2 -2
View File
@@ -12,7 +12,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}
+1 -1
View File
@@ -7,7 +7,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const toggleVariants = cva( 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: { variants: {
variant: { variant: {
+7 -2
View File
@@ -62,8 +62,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({})); const errorData = await response.json().catch(() => ({}));
console.error('Auth check failed:', response.status, errorData); console.error('Auth check failed:', response.status, errorData);
// Any failed /me response means the session is invalid — logout // Only a genuine auth rejection ends the session. Server errors and
logout(); // 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; return;
} }
+26
View File
@@ -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
View File
@@ -4,64 +4,68 @@
@layer base { @layer base {
:root { :root {
--background: 0 0% 100%; /* Sorbet Studio palette (2026-07 app-wide restyle).
--foreground: 222.2 84% 4.9%; 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: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%; --card-foreground: 40 7.5% 15.7%;
--popover: 0 0% 100%; --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: 40 7.5% 15.7%; /* ink - actions stay neutral; coral is accent-only */
--primary-foreground: 210 40% 98%; --primary-foreground: 40 23.1% 97.5%; /* #faf9f7 */
--secondary: 210 40% 96.1%; --secondary: 40 16.7% 92.9%; /* #f0eeea - Studio well */
--secondary-foreground: 222.2 47.4% 11.2%; --secondary-foreground: 40 7.5% 15.7%;
--muted: 210 40% 96.1%; --muted: 40 16.7% 92.9%; /* #f0eeea */
--muted-foreground: 215.4 16.3% 46.9%; --muted-foreground: 40 5.1% 46.3%; /* #7c7870 - Studio muted */
--accent: 210 40% 96.1%; --accent: 40 16.7% 92.9%;
--accent-foreground: 222.2 47.4% 11.2%; --accent-foreground: 40 7.5% 15.7%;
--destructive: 0 84.2% 60.2%; --destructive: 8.8 47.9% 47.5%; /* #b3503f - Studio down */
--destructive-foreground: 210 40% 98%; --destructive-foreground: 40 23.1% 97.5%;
--info: 217.2 91.2% 59.8%; --info: 205.4 46.8% 46.5%; /* #3f7fae - slate blue (chart-secondary) */
--info-foreground: 222.2 47.4% 11.2%; --info-foreground: 40 7.5% 15.7%;
--success: 142.1 76.2% 36.3%; --success: 149.0 55.4% 30.8%; /* #237a4d - Studio up */
--success-foreground: 222.2 47.4% 11.2%; --success-foreground: 40 7.5% 15.7%;
--warning: 45.4 93.4% 47.5%; --warning: 39.1 64.7% 40.0%; /* #a87a24 - Studio amber */
--warning-foreground: 222.2 47.4% 11.2%; --warning-foreground: 40 7.5% 15.7%;
--border: 214.3 31.8% 91.4%; --border: 40 11.1% 89.4%; /* #e7e5e1 - Studio hairline */
--input: 214.3 31.8% 91.4%; --input: 40 11.1% 89.4%;
--ring: 222.2 84% 4.9%; --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-background: 36 17.2% 94.3%; /* #f3f1ee - a step deeper than ground */
--sidebar-foreground: 33.3 5.5% 32.4%; /* #57534e */
--sidebar-primary-foreground: 0 0% 98%; --sidebar-primary: 40 7.5% 15.7%;
--sidebar-primary-foreground: 40 23.1% 97.5%;
--sidebar-accent: 240 4.8% 95.9%; --sidebar-accent: 37.5 16.0% 90.2%; /* #eae7e2 - active/hover well */
--sidebar-accent-foreground: 40 7.5% 15.7%;
--sidebar-accent-foreground: 240 5.9% 10%; --sidebar-border: 40 11.1% 89.4%;
--sidebar-ring: 11.5 70.2% 59.2%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
/* Dashboard card with glass effect */ /* Dashboard card with glass effect */
--card-glass: 0 0% 100%; --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) */ /* Semantic chart colors - Sorbet Studio palette (must match METRIC_COLORS) */
--chart-revenue: 11.5 70.2% 59.2%; /* #e06a4e - Studio coral */ --chart-revenue: 11.5 70.2% 59.2%; /* #e06a4e - Studio coral */
@@ -178,13 +182,13 @@
.dashboard-scroll { .dashboard-scroll {
@apply overflow-y-auto; @apply overflow-y-auto;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: rgb(229 231 235) transparent; scrollbar-color: #e7e5e1 transparent;
} }
.dark .dashboard-scroll { .dark .dashboard-scroll {
scrollbar-color: rgb(55 65 81) transparent; scrollbar-color: rgb(55 65 81) transparent;
} }
.dashboard-scroll:hover { .dashboard-scroll:hover {
scrollbar-color: rgb(209 213 219) transparent; scrollbar-color: #d8d4cd transparent;
} }
.dark .dashboard-scroll:hover { .dark .dashboard-scroll:hover {
scrollbar-color: rgb(75 85 99) transparent; scrollbar-color: rgb(75 85 99) transparent;
@@ -213,11 +217,11 @@
/* Full dashboard controls intentionally stay denser than the rest of the app. */ /* Full dashboard controls intentionally stay denser than the rest of the app. */
.dashboard-page [role="tablist"] { .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"] { .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"] { .dashboard-page [role="combobox"] {
+11 -11
View File
@@ -18,17 +18,17 @@
*/ */
export const CARD_STYLES = { export const CARD_STYLES = {
/** Base card appearance (Sorbet Studio: white panel, warm border, 14px radius) */ /** 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 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 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 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 */ /** 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 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 */ /** Card header layout */
header: "flex flex-row items-center justify-between p-3.5 pb-0", header: "flex flex-row items-center justify-between p-3.5 pb-0",
/** Compact header for stat cards */ /** Compact header for stat cards */
@@ -372,11 +372,11 @@ export const STAT_ICON_STYLES = {
* Table styling tokens * Table styling tokens
*/ */
export const TABLE_STYLES = { export const TABLE_STYLES = {
container: "rounded-xl border border-[#efede9] overflow-hidden", container: "rounded-xl border border-muted overflow-hidden",
header: "bg-[#faf9f7]", header: "bg-surface-subtle",
headerCell: "text-[10.5px] font-semibold text-[#a09b92] uppercase tracking-wide", headerCell: "text-[10.5px] font-semibold text-muted-foreground/75 uppercase tracking-wide",
row: "border-b border-[#f0eeea] last:border-0", row: "border-b border-muted last:border-0",
rowHover: "hover:bg-[#faf9f7] transition-colors", rowHover: "hover:bg-surface-subtle transition-colors",
cell: "text-xs", cell: "text-xs",
cellNumeric: "text-sm tabular-nums text-right", cellNumeric: "text-sm tabular-nums text-right",
} as const; } as const;
+5 -5
View File
@@ -807,7 +807,7 @@ export function BlackFridayDashboard() {
<DollarSign className="h-3.5 w-3.5" /> <DollarSign className="h-3.5 w-3.5" />
Revenue Revenue
</div> </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)} {formatCurrency(currentYearData.totals.revenue, 0)}
</div> </div>
{yoy !== null && ( {yoy !== null && (
@@ -838,7 +838,7 @@ export function BlackFridayDashboard() {
<TrendingUp className="h-3.5 w-3.5" /> <TrendingUp className="h-3.5 w-3.5" />
Profit Profit
</div> </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)} {formatCurrency(currentYearData.totals.profit, 0)}
</div> </div>
{lastYearData && ( {lastYearData && (
@@ -913,7 +913,7 @@ export function BlackFridayDashboard() {
<div className="flex items-center justify-between mb-1.5"> <div className="flex items-center justify-between mb-1.5">
<span className={cn( <span className={cn(
"text-xs font-semibold", "text-xs font-semibold",
isToday ? "text-emerald-600 dark:text-emerald-400" : "text-muted-foreground" isToday ? "text-emerald-600" : "text-muted-foreground"
)}> )}>
{day.label} {day.label}
</span> </span>
@@ -1306,7 +1306,7 @@ export function BlackFridayDashboard() {
> >
<TableCell className={cn( <TableCell className={cn(
"text-xs h-9 pl-4", "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} {year}
</TableCell> </TableCell>
@@ -1319,7 +1319,7 @@ export function BlackFridayDashboard() {
<TableCell className="text-xs h-9 text-right tabular-nums text-muted-foreground"> <TableCell className="text-xs h-9 text-right tabular-nums text-muted-foreground">
{formatCurrency(entry.totals.avgOrderValue, 0)} {formatCurrency(entry.totals.avgOrderValue, 0)}
</TableCell> </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)} {formatCurrency(entry.totals.profit, 0)}
</TableCell> </TableCell>
<TableCell className="text-xs h-9 text-right pr-4"> <TableCell className="text-xs h-9 text-right pr-4">
+2 -2
View File
@@ -529,7 +529,7 @@ function BrandsPanel() {
/> />
{/* Table */} {/* Table */}
<div className="rounded-md border"> <div className="rounded-xl border bg-card overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -728,7 +728,7 @@ function VendorsPanel() {
/> />
{/* Table */} {/* Table */}
<div className="rounded-md border"> <div className="rounded-xl border bg-card overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
+2 -2
View File
@@ -272,7 +272,7 @@ const TypeBadge = ({
}) => { }) => {
const typeLabel = TYPE_LABELS[type] || `Type ${type}`; const typeLabel = TYPE_LABELS[type] || `Type ${type}`;
const colorClass = 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 ( return (
<Badge <Badge
@@ -1284,7 +1284,7 @@ export function Categories() {
</div> </div>
{/* Data Table */} {/* Data Table */}
<div className="rounded-md border"> <div className="rounded-xl border bg-card overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
+69
View File
@@ -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;
+1 -1
View File
@@ -671,7 +671,7 @@ export default function ProductEditor() {
</div> </div>
)} )}
{queryStatus.unsupported.length > 0 && ( {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. {queryStatus.unsupported.length} filter type{queryStatus.unsupported.length !== 1 ? "s" : ""} not supported ({queryStatus.unsupported.join(", ")}). Results may be broader than expected.
</div> </div>
)} )}
+1 -1
View File
@@ -693,7 +693,7 @@ export function ProductLines() {
</div> </div>
{/* Data Table */} {/* Data Table */}
<div className="rounded-md border"> <div className="rounded-xl border bg-card overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
+1 -1
View File
@@ -499,7 +499,7 @@ export function Products() {
placeholder='Search products by name... (press "/")' placeholder='Search products by name... (press "/")'
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 pr-8 h-9" className="pl-9 pr-8 h-9 rounded-full"
/> />
{searchQuery && ( {searchQuery && (
<button <button
+2
View File
@@ -51,6 +51,8 @@ export default {
DEFAULT: 'hsl(var(--muted))', DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))' foreground: 'hsl(var(--muted-foreground))'
}, },
'surface-subtle': 'hsl(var(--surface-subtle))',
coral: 'hsl(var(--coral))',
accent: { accent: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))' foreground: 'hsl(var(--accent-foreground))'
File diff suppressed because one or more lines are too long