diff --git a/inventory-server/dashboard/acot-server/routes/discounts.js b/inventory-server/dashboard/acot-server/routes/discounts.js index 2ef7a3b..efaebdd 100644 --- a/inventory-server/dashboard/acot-server/routes/discounts.js +++ b/inventory-server/dashboard/acot-server/routes/discounts.js @@ -163,6 +163,7 @@ router.post('/simulate', async (req, res) => { productPromo = {}, shippingPromo = {}, shippingTiers = [], + surcharges = [], merchantFeePercent, fixedCostPerOrder, cogsCalculationMode = 'actual', @@ -219,6 +220,17 @@ router.post('/simulate', async (req, res) => { .filter(tier => tier.threshold >= 0 && tier.value >= 0) .sort((a, b) => a.threshold - b.threshold) : [], + surcharges: Array.isArray(surcharges) + ? surcharges + .map(s => ({ + threshold: Number(s.threshold || 0), + maxThreshold: typeof s.maxThreshold === 'number' && s.maxThreshold > 0 ? s.maxThreshold : null, + target: s.target === 'shipping' || s.target === 'order' ? s.target : 'shipping', + amount: Number(s.amount || 0) + })) + .filter(s => s.threshold >= 0 && s.amount >= 0) + .sort((a, b) => a.threshold - b.threshold) + : [], points: { pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null, redemptionRate: typeof pointsConfig.redemptionRate === 'number' ? pointsConfig.redemptionRate : null, @@ -407,7 +419,7 @@ router.post('/simulate', async (req, res) => { }; const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range); - const shippingChargeBase = data.avgShipRate > 0 ? data.avgShipRate : 0; + const shippingChargeBase = data.avgShipCost > 0 ? data.avgShipCost : 0; const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0; // Calculate COGS based on the selected mode @@ -459,8 +471,23 @@ router.post('/simulate', async (req, res) => { shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto); } - const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount); - const customerItemCost = Math.max(0, orderValue - promoProductDiscount); + // Calculate surcharges + let shippingSurcharge = 0; + let orderSurcharge = 0; + for (const surcharge of config.surcharges) { + const meetsMin = orderValue >= surcharge.threshold; + const meetsMax = surcharge.maxThreshold == null || orderValue < surcharge.maxThreshold; + if (meetsMin && meetsMax) { + if (surcharge.target === 'shipping') { + shippingSurcharge += surcharge.amount; + } else if (surcharge.target === 'order') { + orderSurcharge += surcharge.amount; + } + } + } + + const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount + shippingSurcharge); + const customerItemCost = Math.max(0, orderValue - promoProductDiscount + orderSurcharge); const totalRevenue = customerItemCost + customerShipCost; const merchantFees = totalRevenue * (config.merchantFeePercent / 100); @@ -488,6 +515,8 @@ router.post('/simulate', async (req, res) => { shippingChargeBase, shippingAfterAuto, shipPromoDiscount, + shippingSurcharge, + orderSurcharge, customerShipCost, actualShippingCost, totalRevenue, diff --git a/inventory-server/dashboard/acot-server/routes/employee-metrics.js b/inventory-server/dashboard/acot-server/routes/employee-metrics.js new file mode 100644 index 0000000..e78c3b5 --- /dev/null +++ b/inventory-server/dashboard/acot-server/routes/employee-metrics.js @@ -0,0 +1,683 @@ +const express = require('express'); +const { DateTime } = require('luxon'); + +const router = express.Router(); +const { getDbConnection, getPoolStatus } = require('../db/connection'); +const { + getTimeRangeConditions, + _internal: timeHelpers +} = require('../utils/timeUtils'); + +const TIMEZONE = 'America/New_York'; + +// Punch types from the database +const PUNCH_TYPES = { + OUT: 0, + IN: 1, + BREAK_START: 2, + BREAK_END: 3, +}; + +// Standard hours for FTE calculation (40 hours per week) +const STANDARD_WEEKLY_HOURS = 40; + +/** + * Calculate working hours from timeclock entries + * Groups punches by employee and date, pairs in/out punches + * Returns both total hours (with breaks, for FTE) and productive hours (without breaks, for productivity) + */ +function calculateHoursFromPunches(punches) { + // Group by employee + const byEmployee = new Map(); + + punches.forEach(punch => { + if (!byEmployee.has(punch.EmployeeID)) { + byEmployee.set(punch.EmployeeID, []); + } + byEmployee.get(punch.EmployeeID).push(punch); + }); + + const employeeHours = []; + let totalHours = 0; + let totalBreakHours = 0; + + byEmployee.forEach((employeePunches, employeeId) => { + // Sort by timestamp + employeePunches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp)); + + let hours = 0; + let breakHours = 0; + let currentIn = null; + let breakStart = null; + + employeePunches.forEach(punch => { + const punchTime = new Date(punch.TimeStamp); + + switch (punch.PunchType) { + case PUNCH_TYPES.IN: + currentIn = punchTime; + break; + case PUNCH_TYPES.OUT: + if (currentIn) { + hours += (punchTime - currentIn) / (1000 * 60 * 60); // Convert ms to hours + currentIn = null; + } + break; + case PUNCH_TYPES.BREAK_START: + breakStart = punchTime; + break; + case PUNCH_TYPES.BREAK_END: + if (breakStart) { + breakHours += (punchTime - breakStart) / (1000 * 60 * 60); + breakStart = null; + } + break; + } + }); + + totalHours += hours; + totalBreakHours += breakHours; + + employeeHours.push({ + employeeId, + hours, + breakHours, + productiveHours: hours - breakHours, + }); + }); + + return { + employeeHours, + totalHours, + totalBreakHours, + totalProductiveHours: totalHours - totalBreakHours + }; +} + +/** + * Calculate FTE (Full Time Equivalents) for a period + * @param {number} totalHours - Total hours worked + * @param {Date} startDate - Period start + * @param {Date} endDate - Period end + */ +function calculateFTE(totalHours, startDate, endDate) { + const start = new Date(startDate); + const end = new Date(endDate); + const days = Math.max(1, (end - start) / (1000 * 60 * 60 * 24)); + const weeks = days / 7; + const expectedHours = weeks * STANDARD_WEEKLY_HOURS; + + return expectedHours > 0 ? totalHours / expectedHours : 0; +} + +// Main employee metrics endpoint +router.get('/', async (req, res) => { + const startTime = Date.now(); + console.log(`[EMPLOYEE-METRICS] Starting request for timeRange: ${req.query.timeRange}`); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000); + }); + + try { + const mainOperation = async () => { + const { timeRange, startDate, endDate } = req.query; + console.log(`[EMPLOYEE-METRICS] Getting DB connection...`); + const { connection, release } = await getDbConnection(); + console.log(`[EMPLOYEE-METRICS] DB connection obtained in ${Date.now() - startTime}ms`); + + const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); + + // Adapt where clause for timeclock table (uses TimeStamp instead of date_placed) + const timeclockWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp'); + + // Query for timeclock data with employee names + const timeclockQuery = ` + SELECT + tc.EmployeeID, + tc.TimeStamp, + tc.PunchType, + e.firstname, + e.lastname + FROM timeclock tc + LEFT JOIN employees e ON tc.EmployeeID = e.employeeid + WHERE ${timeclockWhere} + AND e.hidden = 0 + AND e.disabled = 0 + ORDER BY tc.EmployeeID, tc.TimeStamp + `; + + const [timeclockRows] = await connection.execute(timeclockQuery, params); + + // Calculate hours (includes both total hours for FTE and productive hours for productivity) + const { employeeHours, totalHours, totalBreakHours, totalProductiveHours } = calculateHoursFromPunches(timeclockRows); + + // Get employee names for the results + const employeeNames = new Map(); + timeclockRows.forEach(row => { + if (!employeeNames.has(row.EmployeeID)) { + employeeNames.set(row.EmployeeID, { + firstname: row.firstname || '', + lastname: row.lastname || '', + }); + } + }); + + // Enrich employee hours with names + const enrichedEmployeeHours = employeeHours.map(eh => ({ + ...eh, + name: employeeNames.has(eh.employeeId) + ? `${employeeNames.get(eh.employeeId).firstname} ${employeeNames.get(eh.employeeId).lastname}`.trim() + : `Employee ${eh.employeeId}`, + })).sort((a, b) => b.hours - a.hours); + + // Query for picking tickets - using subquery to avoid duplication from bucket join + // Ship-together orders: only count main orders (is_sub = 0 or NULL), not sub-orders + const pickingWhere = whereClause.replace(/date_placed/g, 'pt.createddate'); + + // First get picking ticket stats without the bucket join (to avoid duplication) + const pickingStatsQuery = ` + SELECT + pt.createdby as employeeId, + e.firstname, + e.lastname, + COUNT(DISTINCT pt.pickingid) as ticketCount, + SUM(pt.totalpieces_picked) as piecesPicked, + SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds, + AVG(NULLIF(pt.picking_speed, 0)) as avgPickingSpeed + FROM picking_ticket pt + LEFT JOIN employees e ON pt.createdby = e.employeeid + WHERE ${pickingWhere} + AND pt.closeddate IS NOT NULL + GROUP BY pt.createdby, e.firstname, e.lastname + `; + + // Separate query for order counts (needs bucket join for ship-together handling) + const orderCountQuery = ` + SELECT + pt.createdby as employeeId, + COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked + FROM picking_ticket pt + LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid + WHERE ${pickingWhere} + AND pt.closeddate IS NOT NULL + GROUP BY pt.createdby + `; + + const [[pickingStatsRows], [orderCountRows]] = await Promise.all([ + connection.execute(pickingStatsQuery, params), + connection.execute(orderCountQuery, params) + ]); + + // Merge the results + const orderCountMap = new Map(); + orderCountRows.forEach(row => { + orderCountMap.set(row.employeeId, parseInt(row.ordersPicked || 0)); + }); + + // Aggregate picking totals + let totalOrdersPicked = 0; + let totalPiecesPicked = 0; + let totalTickets = 0; + let totalPickingTimeSeconds = 0; + let pickingSpeedSum = 0; + let pickingSpeedCount = 0; + + const pickingByEmployee = pickingStatsRows.map(row => { + const ordersPicked = orderCountMap.get(row.employeeId) || 0; + totalOrdersPicked += ordersPicked; + totalPiecesPicked += parseInt(row.piecesPicked || 0); + totalTickets += parseInt(row.ticketCount || 0); + totalPickingTimeSeconds += parseInt(row.pickingTimeSeconds || 0); + if (row.avgPickingSpeed && row.avgPickingSpeed > 0) { + pickingSpeedSum += parseFloat(row.avgPickingSpeed); + pickingSpeedCount++; + } + + const empPickingHours = parseInt(row.pickingTimeSeconds || 0) / 3600; + + return { + employeeId: row.employeeId, + name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeId}`, + ticketCount: parseInt(row.ticketCount || 0), + ordersPicked, + piecesPicked: parseInt(row.piecesPicked || 0), + pickingHours: empPickingHours, + avgPickingSpeed: row.avgPickingSpeed ? parseFloat(row.avgPickingSpeed) : null, + }; + }); + + const totalPickingHours = totalPickingTimeSeconds / 3600; + const avgPickingSpeed = pickingSpeedCount > 0 ? pickingSpeedSum / pickingSpeedCount : 0; + + // Query for shipped orders - totals + // Ship-together orders: only count main orders (order_type != 8 for sub-orders, or use parent tracking) + const shippingWhere = whereClause.replace(/date_placed/g, 'o.date_shipped'); + + const shippingQuery = ` + SELECT + COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped, + COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped + FROM _order o + WHERE ${shippingWhere} + AND o.order_status IN (100, 92) + `; + + const [shippingRows] = await connection.execute(shippingQuery, params); + const shipping = shippingRows[0] || { ordersShipped: 0, piecesShipped: 0 }; + + // Query for shipped orders by employee + const shippingByEmployeeQuery = ` + SELECT + e.employeeid, + e.firstname, + e.lastname, + COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped, + COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped + FROM _order o + JOIN employees e ON o.stats_cid_shipped = e.cid + WHERE ${shippingWhere} + AND o.order_status IN (100, 92) + AND e.hidden = 0 + AND e.disabled = 0 + GROUP BY e.employeeid, e.firstname, e.lastname + ORDER BY ordersShipped DESC + `; + + const [shippingByEmployeeRows] = await connection.execute(shippingByEmployeeQuery, params); + const shippingByEmployee = shippingByEmployeeRows.map(row => ({ + employeeId: row.employeeid, + name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeid}`, + ordersShipped: parseInt(row.ordersShipped || 0), + piecesShipped: parseInt(row.piecesShipped || 0), + })); + + // Calculate period dates for FTE calculation + let periodStart, periodEnd; + if (dateRange?.start) { + periodStart = new Date(dateRange.start); + } else if (params[0]) { + periodStart = new Date(params[0]); + } else { + periodStart = new Date(); + periodStart.setDate(periodStart.getDate() - 30); + } + + if (dateRange?.end) { + periodEnd = new Date(dateRange.end); + } else if (params[1]) { + periodEnd = new Date(params[1]); + } else { + periodEnd = new Date(); + } + + const fte = calculateFTE(totalHours, periodStart, periodEnd); + const activeEmployees = enrichedEmployeeHours.filter(e => e.hours > 0).length; + + // Calculate weeks in period for weekly averages + const periodDays = Math.max(1, (periodEnd - periodStart) / (1000 * 60 * 60 * 24)); + const weeksInPeriod = periodDays / 7; + + // Get daily trend data for hours + // Use DATE_FORMAT to get date string in Eastern timezone, avoiding JS timezone conversion issues + // Business day starts at 1 AM, so subtract 1 hour before taking the date + const trendWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp'); + const trendQuery = ` + SELECT + DATE_FORMAT(DATE_SUB(tc.TimeStamp, INTERVAL 1 HOUR), '%Y-%m-%d') as date, + tc.EmployeeID, + tc.TimeStamp, + tc.PunchType + FROM timeclock tc + LEFT JOIN employees e ON tc.EmployeeID = e.employeeid + WHERE ${trendWhere} + AND e.hidden = 0 + AND e.disabled = 0 + ORDER BY date, tc.EmployeeID, tc.TimeStamp + `; + + const [trendRows] = await connection.execute(trendQuery, params); + + // Get daily picking data for trend + // Ship-together orders: only count main orders (is_sub = 0 or NULL) + // Use DATE_FORMAT for consistent date string format + const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate'); + const pickingTrendQuery = ` + SELECT + DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date, + COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked, + COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked + FROM picking_ticket pt + LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid + WHERE ${pickingTrendWhere} + AND pt.closeddate IS NOT NULL + GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') + ORDER BY date + `; + + const [pickingTrendRows] = await connection.execute(pickingTrendQuery, params); + + // Create a map of picking data by date + const pickingByDate = new Map(); + pickingTrendRows.forEach(row => { + // Date is already a string in YYYY-MM-DD format from DATE_FORMAT + const date = String(row.date); + pickingByDate.set(date, { + ordersPicked: parseInt(row.ordersPicked || 0), + piecesPicked: parseInt(row.piecesPicked || 0), + }); + }); + + // Group timeclock by date for trend + const byDate = new Map(); + trendRows.forEach(row => { + // Date is already a string in YYYY-MM-DD format from DATE_FORMAT + const date = String(row.date); + if (!byDate.has(date)) { + byDate.set(date, []); + } + byDate.get(date).push(row); + }); + + // Generate all dates in the period range for complete trend data + const allDatesInRange = []; + const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day'); + const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day'); + + let currentDt = startDt; + while (currentDt <= endDt) { + allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd')); + currentDt = currentDt.plus({ days: 1 }); + } + + // Build trend data for all dates in range, filling zeros for missing days + const trend = allDatesInRange.map(date => { + const punches = byDate.get(date) || []; + const { totalHours: dayHours, employeeHours: dayEmployeeHours } = calculateHoursFromPunches(punches); + const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 }; + + // Parse date string in Eastern timezone to get proper ISO timestamp + const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE }); + + return { + date, + timestamp: dateDt.toISO(), + hours: dayHours, + activeEmployees: dayEmployeeHours.filter(e => e.hours > 0).length, + ordersPicked: picking.ordersPicked, + piecesPicked: picking.piecesPicked, + }; + }); + + // Get previous period data for comparison + const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate); + let comparison = null; + let previousTotals = null; + + if (previousRange) { + const prevTimeclockWhere = previousRange.whereClause.replace(/date_placed/g, 'tc.TimeStamp'); + + const [prevTimeclockRows] = await connection.execute( + `SELECT tc.EmployeeID, tc.TimeStamp, tc.PunchType + FROM timeclock tc + LEFT JOIN employees e ON tc.EmployeeID = e.employeeid + WHERE ${prevTimeclockWhere} + AND e.hidden = 0 + AND e.disabled = 0 + ORDER BY tc.EmployeeID, tc.TimeStamp`, + previousRange.params + ); + + const { + totalHours: prevTotalHours, + totalProductiveHours: prevProductiveHours, + employeeHours: prevEmployeeHours + } = calculateHoursFromPunches(prevTimeclockRows); + const prevActiveEmployees = prevEmployeeHours.filter(e => e.hours > 0).length; + + // Previous picking data (ship-together fix applied) + // Use separate queries to avoid duplication from bucket join + const prevPickingWhere = previousRange.whereClause.replace(/date_placed/g, 'pt.createddate'); + + const [[prevPickingStatsRows], [prevOrderCountRows]] = await Promise.all([ + connection.execute( + `SELECT + SUM(pt.totalpieces_picked) as piecesPicked, + SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds + FROM picking_ticket pt + WHERE ${prevPickingWhere} + AND pt.closeddate IS NOT NULL`, + previousRange.params + ), + connection.execute( + `SELECT + COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked + FROM picking_ticket pt + LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid + WHERE ${prevPickingWhere} + AND pt.closeddate IS NOT NULL`, + previousRange.params + ) + ]); + + const prevPickingStats = prevPickingStatsRows[0] || { piecesPicked: 0, pickingTimeSeconds: 0 }; + const prevOrderCount = prevOrderCountRows[0] || { ordersPicked: 0 }; + const prevPicking = { + ordersPicked: parseInt(prevOrderCount.ordersPicked || 0), + piecesPicked: parseInt(prevPickingStats.piecesPicked || 0), + pickingTimeSeconds: parseInt(prevPickingStats.pickingTimeSeconds || 0) + }; + const prevPickingHours = prevPicking.pickingTimeSeconds / 3600; + + // Previous shipping data + const prevShippingWhere = previousRange.whereClause.replace(/date_placed/g, 'o.date_shipped'); + const [prevShippingRows] = await connection.execute( + `SELECT + COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped, + COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped + FROM _order o + WHERE ${prevShippingWhere} + AND o.order_status IN (100, 92)`, + previousRange.params + ); + const prevShipping = prevShippingRows[0] || { ordersShipped: 0, piecesShipped: 0 }; + + // Calculate previous period FTE and productivity + const prevFte = calculateFTE(prevTotalHours, previousRange.start || periodStart, previousRange.end || periodEnd); + const prevOrdersPerHour = prevProductiveHours > 0 ? parseInt(prevPicking.ordersPicked || 0) / prevProductiveHours : 0; + const prevPiecesPerHour = prevProductiveHours > 0 ? parseInt(prevPicking.piecesPicked || 0) / prevProductiveHours : 0; + + previousTotals = { + hours: prevTotalHours, + productiveHours: prevProductiveHours, + activeEmployees: prevActiveEmployees, + fte: prevFte, + ordersPicked: parseInt(prevPicking.ordersPicked || 0), + piecesPicked: parseInt(prevPicking.piecesPicked || 0), + pickingHours: prevPickingHours, + ordersShipped: parseInt(prevShipping.ordersShipped || 0), + piecesShipped: parseInt(prevShipping.piecesShipped || 0), + ordersPerHour: prevOrdersPerHour, + piecesPerHour: prevPiecesPerHour, + }; + + // Calculate productivity metrics for comparison + const currentOrdersPerHour = totalProductiveHours > 0 ? totalOrdersPicked / totalProductiveHours : 0; + const currentPiecesPerHour = totalProductiveHours > 0 ? totalPiecesPicked / totalProductiveHours : 0; + + comparison = { + hours: calculateComparison(totalHours, prevTotalHours), + productiveHours: calculateComparison(totalProductiveHours, prevProductiveHours), + activeEmployees: calculateComparison(activeEmployees, prevActiveEmployees), + fte: calculateComparison(fte, prevFte), + ordersPicked: calculateComparison(totalOrdersPicked, parseInt(prevPicking.ordersPicked || 0)), + piecesPicked: calculateComparison(totalPiecesPicked, parseInt(prevPicking.piecesPicked || 0)), + ordersShipped: calculateComparison(parseInt(shipping.ordersShipped || 0), parseInt(prevShipping.ordersShipped || 0)), + piecesShipped: calculateComparison(parseInt(shipping.piecesShipped || 0), parseInt(prevShipping.piecesShipped || 0)), + ordersPerHour: calculateComparison(currentOrdersPerHour, prevOrdersPerHour), + piecesPerHour: calculateComparison(currentPiecesPerHour, prevPiecesPerHour), + }; + } + + // Calculate efficiency (picking time vs productive hours) + const pickingEfficiency = totalProductiveHours > 0 ? (totalPickingHours / totalProductiveHours) * 100 : 0; + + const response = { + dateRange, + totals: { + // Time metrics + hours: totalHours, + breakHours: totalBreakHours, + productiveHours: totalProductiveHours, + pickingHours: totalPickingHours, + + // Employee metrics + activeEmployees, + fte, + weeksInPeriod, + + // Picking metrics + ordersPicked: totalOrdersPicked, + piecesPicked: totalPiecesPicked, + ticketCount: totalTickets, + + // Shipping metrics + ordersShipped: parseInt(shipping.ordersShipped || 0), + piecesShipped: parseInt(shipping.piecesShipped || 0), + + // Calculated metrics - standardized to weekly + hoursPerWeek: weeksInPeriod > 0 ? totalHours / weeksInPeriod : 0, + hoursPerEmployeePerWeek: activeEmployees > 0 && weeksInPeriod > 0 + ? (totalHours / activeEmployees) / weeksInPeriod + : 0, + + // Productivity metrics (uses productive hours - excludes breaks) + ordersPerHour: totalProductiveHours > 0 ? totalOrdersPicked / totalProductiveHours : 0, + piecesPerHour: totalProductiveHours > 0 ? totalPiecesPicked / totalProductiveHours : 0, + + // Picking speed from database (more accurate, only counts picking time) + avgPickingSpeed, + + // Efficiency metrics + pickingEfficiency, + }, + previousTotals, + comparison, + byEmployee: { + hours: enrichedEmployeeHours, + picking: pickingByEmployee, + shipping: shippingByEmployee, + }, + trend, + }; + + return { response, release }; + }; + + let result; + try { + result = await Promise.race([mainOperation(), timeoutPromise]); + } catch (error) { + if (error.message.includes('timeout')) { + console.log(`[EMPLOYEE-METRICS] Request timed out in ${Date.now() - startTime}ms`); + throw error; + } + throw error; + } + + const { response, release } = result; + + if (release) release(); + + console.log(`[EMPLOYEE-METRICS] Request completed in ${Date.now() - startTime}ms`); + res.json(response); + + } catch (error) { + console.error('Error in /employee-metrics:', error); + console.log(`[EMPLOYEE-METRICS] Request failed in ${Date.now() - startTime}ms`); + res.status(500).json({ error: error.message }); + } +}); + +// Health check +router.get('/health', async (req, res) => { + try { + const { connection, release } = await getDbConnection(); + await connection.execute('SELECT 1 as test'); + release(); + + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + pool: getPoolStatus(), + }); + } catch (error) { + res.status(500).json({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error.message, + }); + } +}); + +// Helper functions +function calculateComparison(currentValue, previousValue) { + if (typeof previousValue !== 'number') { + return { absolute: null, percentage: null }; + } + + const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null; + const percentage = + absolute !== null && previousValue !== 0 + ? (absolute / Math.abs(previousValue)) * 100 + : null; + + return { absolute, percentage }; +} + +function getPreviousPeriodRange(timeRange, startDate, endDate) { + if (timeRange && timeRange !== 'custom') { + const prevTimeRange = getPreviousTimeRange(timeRange); + if (!prevTimeRange || prevTimeRange === timeRange) { + return null; + } + return getTimeRangeConditions(prevTimeRange); + } + + const hasCustomDates = (timeRange === 'custom' || !timeRange) && startDate && endDate; + if (!hasCustomDates) { + return null; + } + + const start = new Date(startDate); + const end = new Date(endDate); + + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + + const duration = end.getTime() - start.getTime(); + if (!Number.isFinite(duration) || duration <= 0) { + return null; + } + + const prevEnd = new Date(start.getTime() - 1); + const prevStart = new Date(prevEnd.getTime() - duration); + + return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString()); +} + +function getPreviousTimeRange(timeRange) { + const map = { + today: 'yesterday', + thisWeek: 'lastWeek', + thisMonth: 'lastMonth', + last7days: 'previous7days', + last30days: 'previous30days', + last90days: 'previous90days', + yesterday: 'twoDaysAgo' + }; + return map[timeRange] || timeRange; +} + +module.exports = router; diff --git a/inventory-server/dashboard/acot-server/routes/operations-metrics.js b/inventory-server/dashboard/acot-server/routes/operations-metrics.js new file mode 100644 index 0000000..632cdbe --- /dev/null +++ b/inventory-server/dashboard/acot-server/routes/operations-metrics.js @@ -0,0 +1,470 @@ +const express = require('express'); +const { DateTime } = require('luxon'); + +const router = express.Router(); +const { getDbConnection, getPoolStatus } = require('../db/connection'); +const { + getTimeRangeConditions, +} = require('../utils/timeUtils'); + +const TIMEZONE = 'America/New_York'; + +// Main operations metrics endpoint - focused on picking and shipping +router.get('/', async (req, res) => { + const startTime = Date.now(); + console.log(`[OPERATIONS-METRICS] Starting request for timeRange: ${req.query.timeRange}`); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000); + }); + + try { + const mainOperation = async () => { + const { timeRange, startDate, endDate } = req.query; + console.log(`[OPERATIONS-METRICS] Getting DB connection...`); + const { connection, release } = await getDbConnection(); + console.log(`[OPERATIONS-METRICS] DB connection obtained in ${Date.now() - startTime}ms`); + + const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate); + + // Query for picking tickets - using subquery to avoid duplication from bucket join + // Ship-together orders: only count main orders (is_sub = 0 or NULL), not sub-orders + const pickingWhere = whereClause.replace(/date_placed/g, 'pt.createddate'); + + // First get picking ticket stats without the bucket join (to avoid duplication) + const pickingStatsQuery = ` + SELECT + pt.createdby as employeeId, + e.firstname, + e.lastname, + COUNT(DISTINCT pt.pickingid) as ticketCount, + SUM(pt.totalpieces_picked) as piecesPicked, + SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds, + AVG(NULLIF(pt.picking_speed, 0)) as avgPickingSpeed + FROM picking_ticket pt + LEFT JOIN employees e ON pt.createdby = e.employeeid + WHERE ${pickingWhere} + AND pt.closeddate IS NOT NULL + GROUP BY pt.createdby, e.firstname, e.lastname + `; + + // Separate query for order counts (needs bucket join for ship-together handling) + const orderCountQuery = ` + SELECT + pt.createdby as employeeId, + COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked + FROM picking_ticket pt + LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid + WHERE ${pickingWhere} + AND pt.closeddate IS NOT NULL + GROUP BY pt.createdby + `; + + const [[pickingStatsRows], [orderCountRows]] = await Promise.all([ + connection.execute(pickingStatsQuery, params), + connection.execute(orderCountQuery, params) + ]); + + // Merge the results + const orderCountMap = new Map(); + orderCountRows.forEach(row => { + orderCountMap.set(row.employeeId, parseInt(row.ordersPicked || 0)); + }); + + // Aggregate picking totals + let totalOrdersPicked = 0; + let totalPiecesPicked = 0; + let totalTickets = 0; + let totalPickingTimeSeconds = 0; + let pickingSpeedSum = 0; + let pickingSpeedCount = 0; + + const pickingByEmployee = pickingStatsRows.map(row => { + const ordersPicked = orderCountMap.get(row.employeeId) || 0; + totalOrdersPicked += ordersPicked; + totalPiecesPicked += parseInt(row.piecesPicked || 0); + totalTickets += parseInt(row.ticketCount || 0); + totalPickingTimeSeconds += parseInt(row.pickingTimeSeconds || 0); + if (row.avgPickingSpeed && row.avgPickingSpeed > 0) { + pickingSpeedSum += parseFloat(row.avgPickingSpeed); + pickingSpeedCount++; + } + + const empPickingHours = parseInt(row.pickingTimeSeconds || 0) / 3600; + + return { + employeeId: row.employeeId, + name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeId}`, + ticketCount: parseInt(row.ticketCount || 0), + ordersPicked, + piecesPicked: parseInt(row.piecesPicked || 0), + pickingHours: empPickingHours, + avgPickingSpeed: row.avgPickingSpeed ? parseFloat(row.avgPickingSpeed) : null, + }; + }); + + const totalPickingHours = totalPickingTimeSeconds / 3600; + const avgPickingSpeed = pickingSpeedCount > 0 ? pickingSpeedSum / pickingSpeedCount : 0; + + // Query for shipped orders - totals + // Ship-together orders: only count main orders (order_type != 8 for sub-orders) + const shippingWhere = whereClause.replace(/date_placed/g, 'o.date_shipped'); + + const shippingQuery = ` + SELECT + COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped, + COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped + FROM _order o + WHERE ${shippingWhere} + AND o.order_status IN (100, 92) + `; + + const [shippingRows] = await connection.execute(shippingQuery, params); + const shipping = shippingRows[0] || { ordersShipped: 0, piecesShipped: 0 }; + + // Query for shipped orders by employee + const shippingByEmployeeQuery = ` + SELECT + e.employeeid, + e.firstname, + e.lastname, + COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped, + COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped + FROM _order o + JOIN employees e ON o.stats_cid_shipped = e.cid + WHERE ${shippingWhere} + AND o.order_status IN (100, 92) + AND e.hidden = 0 + AND e.disabled = 0 + GROUP BY e.employeeid, e.firstname, e.lastname + ORDER BY ordersShipped DESC + `; + + const [shippingByEmployeeRows] = await connection.execute(shippingByEmployeeQuery, params); + const shippingByEmployee = shippingByEmployeeRows.map(row => ({ + employeeId: row.employeeid, + name: `${row.firstname || ''} ${row.lastname || ''}`.trim() || `Employee ${row.employeeid}`, + ordersShipped: parseInt(row.ordersShipped || 0), + piecesShipped: parseInt(row.piecesShipped || 0), + })); + + // Calculate period dates + let periodStart, periodEnd; + if (dateRange?.start) { + periodStart = new Date(dateRange.start); + } else if (params[0]) { + periodStart = new Date(params[0]); + } else { + periodStart = new Date(); + periodStart.setDate(periodStart.getDate() - 30); + } + + if (dateRange?.end) { + periodEnd = new Date(dateRange.end); + } else if (params[1]) { + periodEnd = new Date(params[1]); + } else { + periodEnd = new Date(); + } + + // Calculate productivity (orders/pieces per picking hour) + const ordersPerHour = totalPickingHours > 0 ? totalOrdersPicked / totalPickingHours : 0; + const piecesPerHour = totalPickingHours > 0 ? totalPiecesPicked / totalPickingHours : 0; + + // Get daily trend data for picking + // Use DATE_FORMAT to get date string in Eastern timezone + // Business day starts at 1 AM, so subtract 1 hour before taking the date + const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate'); + const pickingTrendQuery = ` + SELECT + DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date, + COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked, + COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked + FROM picking_ticket pt + LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid + WHERE ${pickingTrendWhere} + AND pt.closeddate IS NOT NULL + GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') + ORDER BY date + `; + + // Get shipping trend data + const shippingTrendWhere = whereClause.replace(/date_placed/g, 'o.date_shipped'); + const shippingTrendQuery = ` + SELECT + DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d') as date, + COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped, + COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped + FROM _order o + WHERE ${shippingTrendWhere} + AND o.order_status IN (100, 92) + GROUP BY DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d') + ORDER BY date + `; + + const [[pickingTrendRows], [shippingTrendRows]] = await Promise.all([ + connection.execute(pickingTrendQuery, params), + connection.execute(shippingTrendQuery, params), + ]); + + // Create maps for trend data + const pickingByDate = new Map(); + pickingTrendRows.forEach(row => { + const date = String(row.date); + pickingByDate.set(date, { + ordersPicked: parseInt(row.ordersPicked || 0), + piecesPicked: parseInt(row.piecesPicked || 0), + }); + }); + + const shippingByDate = new Map(); + shippingTrendRows.forEach(row => { + const date = String(row.date); + shippingByDate.set(date, { + ordersShipped: parseInt(row.ordersShipped || 0), + piecesShipped: parseInt(row.piecesShipped || 0), + }); + }); + + // Generate all dates in the period range for complete trend data + const allDatesInRange = []; + const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day'); + const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day'); + + let currentDt = startDt; + while (currentDt <= endDt) { + allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd')); + currentDt = currentDt.plus({ days: 1 }); + } + + // Build trend data for all dates in range + const trend = allDatesInRange.map(date => { + const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 }; + const shippingData = shippingByDate.get(date) || { ordersShipped: 0, piecesShipped: 0 }; + + // Parse date string in Eastern timezone to get proper ISO timestamp + const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE }); + + return { + date, + timestamp: dateDt.toISO(), + ordersPicked: picking.ordersPicked, + piecesPicked: picking.piecesPicked, + ordersShipped: shippingData.ordersShipped, + piecesShipped: shippingData.piecesShipped, + }; + }); + + // Get previous period data for comparison + const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate); + let comparison = null; + let previousTotals = null; + + if (previousRange) { + // Previous picking data + const prevPickingWhere = previousRange.whereClause.replace(/date_placed/g, 'pt.createddate'); + + const [[prevPickingStatsRows], [prevOrderCountRows]] = await Promise.all([ + connection.execute( + `SELECT + SUM(pt.totalpieces_picked) as piecesPicked, + SUM(TIMESTAMPDIFF(SECOND, pt.createddate, pt.closeddate)) as pickingTimeSeconds + FROM picking_ticket pt + WHERE ${prevPickingWhere} + AND pt.closeddate IS NOT NULL`, + previousRange.params + ), + connection.execute( + `SELECT + COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked + FROM picking_ticket pt + LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid + WHERE ${prevPickingWhere} + AND pt.closeddate IS NOT NULL`, + previousRange.params + ) + ]); + + const prevPickingStats = prevPickingStatsRows[0] || { piecesPicked: 0, pickingTimeSeconds: 0 }; + const prevOrderCount = prevOrderCountRows[0] || { ordersPicked: 0 }; + const prevPicking = { + ordersPicked: parseInt(prevOrderCount.ordersPicked || 0), + piecesPicked: parseInt(prevPickingStats.piecesPicked || 0), + pickingTimeSeconds: parseInt(prevPickingStats.pickingTimeSeconds || 0) + }; + const prevPickingHours = prevPicking.pickingTimeSeconds / 3600; + + // Previous shipping data + const prevShippingWhere = previousRange.whereClause.replace(/date_placed/g, 'o.date_shipped'); + const [prevShippingRows] = await connection.execute( + `SELECT + COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped, + COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped + FROM _order o + WHERE ${prevShippingWhere} + AND o.order_status IN (100, 92)`, + previousRange.params + ); + const prevShipping = prevShippingRows[0] || { ordersShipped: 0, piecesShipped: 0 }; + + // Calculate previous productivity + const prevOrdersPerHour = prevPickingHours > 0 ? parseInt(prevPicking.ordersPicked || 0) / prevPickingHours : 0; + const prevPiecesPerHour = prevPickingHours > 0 ? parseInt(prevPicking.piecesPicked || 0) / prevPickingHours : 0; + + previousTotals = { + ordersPicked: parseInt(prevPicking.ordersPicked || 0), + piecesPicked: parseInt(prevPicking.piecesPicked || 0), + pickingHours: prevPickingHours, + ordersShipped: parseInt(prevShipping.ordersShipped || 0), + piecesShipped: parseInt(prevShipping.piecesShipped || 0), + ordersPerHour: prevOrdersPerHour, + piecesPerHour: prevPiecesPerHour, + }; + + comparison = { + ordersPicked: calculateComparison(totalOrdersPicked, parseInt(prevPicking.ordersPicked || 0)), + piecesPicked: calculateComparison(totalPiecesPicked, parseInt(prevPicking.piecesPicked || 0)), + ordersShipped: calculateComparison(parseInt(shipping.ordersShipped || 0), parseInt(prevShipping.ordersShipped || 0)), + piecesShipped: calculateComparison(parseInt(shipping.piecesShipped || 0), parseInt(prevShipping.piecesShipped || 0)), + ordersPerHour: calculateComparison(ordersPerHour, prevOrdersPerHour), + piecesPerHour: calculateComparison(piecesPerHour, prevPiecesPerHour), + }; + } + + const response = { + dateRange, + totals: { + // Picking metrics + ordersPicked: totalOrdersPicked, + piecesPicked: totalPiecesPicked, + ticketCount: totalTickets, + pickingHours: totalPickingHours, + + // Shipping metrics + ordersShipped: parseInt(shipping.ordersShipped || 0), + piecesShipped: parseInt(shipping.piecesShipped || 0), + + // Productivity metrics + ordersPerHour, + piecesPerHour, + avgPickingSpeed, + }, + previousTotals, + comparison, + byEmployee: { + picking: pickingByEmployee, + shipping: shippingByEmployee, + }, + trend, + }; + + return { response, release }; + }; + + let result; + try { + result = await Promise.race([mainOperation(), timeoutPromise]); + } catch (error) { + if (error.message.includes('timeout')) { + console.log(`[OPERATIONS-METRICS] Request timed out in ${Date.now() - startTime}ms`); + throw error; + } + throw error; + } + + const { response, release } = result; + + if (release) release(); + + console.log(`[OPERATIONS-METRICS] Request completed in ${Date.now() - startTime}ms`); + res.json(response); + + } catch (error) { + console.error('Error in /operations-metrics:', error); + console.log(`[OPERATIONS-METRICS] Request failed in ${Date.now() - startTime}ms`); + res.status(500).json({ error: error.message }); + } +}); + +// Health check +router.get('/health', async (req, res) => { + try { + const { connection, release } = await getDbConnection(); + await connection.execute('SELECT 1 as test'); + release(); + + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + pool: getPoolStatus(), + }); + } catch (error) { + res.status(500).json({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error.message, + }); + } +}); + +// Helper functions +function calculateComparison(currentValue, previousValue) { + if (typeof previousValue !== 'number') { + return { absolute: null, percentage: null }; + } + + const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null; + const percentage = + absolute !== null && previousValue !== 0 + ? (absolute / Math.abs(previousValue)) * 100 + : null; + + return { absolute, percentage }; +} + +function getPreviousPeriodRange(timeRange, startDate, endDate) { + if (timeRange && timeRange !== 'custom') { + const prevTimeRange = getPreviousTimeRange(timeRange); + if (!prevTimeRange || prevTimeRange === timeRange) { + return null; + } + return getTimeRangeConditions(prevTimeRange); + } + + const hasCustomDates = (timeRange === 'custom' || !timeRange) && startDate && endDate; + if (!hasCustomDates) { + return null; + } + + const start = new Date(startDate); + const end = new Date(endDate); + + if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) { + return null; + } + + const duration = end.getTime() - start.getTime(); + if (!Number.isFinite(duration) || duration <= 0) { + return null; + } + + const prevEnd = new Date(start.getTime() - 1); + const prevStart = new Date(prevEnd.getTime() - duration); + + return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString()); +} + +function getPreviousTimeRange(timeRange) { + const map = { + today: 'yesterday', + thisWeek: 'lastWeek', + thisMonth: 'lastMonth', + last7days: 'previous7days', + last30days: 'previous30days', + last90days: 'previous90days', + yesterday: 'twoDaysAgo' + }; + return map[timeRange] || timeRange; +} + +module.exports = router; diff --git a/inventory-server/dashboard/acot-server/routes/payroll-metrics.js b/inventory-server/dashboard/acot-server/routes/payroll-metrics.js new file mode 100644 index 0000000..47fc76d --- /dev/null +++ b/inventory-server/dashboard/acot-server/routes/payroll-metrics.js @@ -0,0 +1,495 @@ +const express = require('express'); +const { DateTime } = require('luxon'); + +const router = express.Router(); +const { getDbConnection, getPoolStatus } = require('../db/connection'); + +const TIMEZONE = 'America/New_York'; + +// Punch types from the database +const PUNCH_TYPES = { + OUT: 0, + IN: 1, + BREAK_START: 2, + BREAK_END: 3, +}; + +// Standard hours for overtime calculation (40 hours per week) +const STANDARD_WEEKLY_HOURS = 40; + +// Reference pay period start date (January 25, 2026 is a Sunday, first day of a pay period) +const PAY_PERIOD_REFERENCE = DateTime.fromObject( + { year: 2026, month: 1, day: 25 }, + { zone: TIMEZONE } +); + +/** + * Calculate the pay period that contains a given date + * Pay periods are 14 days starting on Sunday + * @param {DateTime} date - The date to find the pay period for + * @returns {{ start: DateTime, end: DateTime, week1: { start: DateTime, end: DateTime }, week2: { start: DateTime, end: DateTime } }} + */ +function getPayPeriodForDate(date) { + const dt = DateTime.isDateTime(date) ? date : DateTime.fromJSDate(date, { zone: TIMEZONE }); + + // Calculate days since reference + const daysSinceReference = Math.floor(dt.diff(PAY_PERIOD_REFERENCE, 'days').days); + + // Find which pay period this falls into (can be negative for dates before reference) + const payPeriodIndex = Math.floor(daysSinceReference / 14); + + // Calculate the start of this pay period + const start = PAY_PERIOD_REFERENCE.plus({ days: payPeriodIndex * 14 }).startOf('day'); + const end = start.plus({ days: 13 }).endOf('day'); + + // Week 1: Sunday through Saturday + const week1Start = start; + const week1End = start.plus({ days: 6 }).endOf('day'); + + // Week 2: Sunday through Saturday + const week2Start = start.plus({ days: 7 }).startOf('day'); + const week2End = end; + + return { + start, + end, + week1: { start: week1Start, end: week1End }, + week2: { start: week2Start, end: week2End }, + }; +} + +/** + * Get the current pay period + */ +function getCurrentPayPeriod() { + return getPayPeriodForDate(DateTime.now().setZone(TIMEZONE)); +} + +/** + * Navigate to previous or next pay period + * @param {DateTime} currentStart - Current pay period start + * @param {number} offset - Number of pay periods to move (negative for previous) + */ +function navigatePayPeriod(currentStart, offset) { + const newStart = currentStart.plus({ days: offset * 14 }); + return getPayPeriodForDate(newStart); +} + +/** + * Calculate working hours from timeclock entries, broken down by week + * @param {Array} punches - Timeclock punch entries + * @param {Object} payPeriod - Pay period with week boundaries + */ +function calculateHoursByWeek(punches, payPeriod) { + // Group by employee + const byEmployee = new Map(); + + punches.forEach(punch => { + if (!byEmployee.has(punch.EmployeeID)) { + byEmployee.set(punch.EmployeeID, { + employeeId: punch.EmployeeID, + firstname: punch.firstname || '', + lastname: punch.lastname || '', + punches: [], + }); + } + byEmployee.get(punch.EmployeeID).punches.push(punch); + }); + + const employeeResults = []; + let totalHours = 0; + let totalBreakHours = 0; + let totalOvertimeHours = 0; + let totalRegularHours = 0; + let week1TotalHours = 0; + let week1TotalOvertime = 0; + let week2TotalHours = 0; + let week2TotalOvertime = 0; + + byEmployee.forEach((employeeData) => { + // Sort punches by timestamp + employeeData.punches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp)); + + // Calculate hours for each week + const week1Punches = employeeData.punches.filter(p => { + const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE }); + return dt >= payPeriod.week1.start && dt <= payPeriod.week1.end; + }); + + const week2Punches = employeeData.punches.filter(p => { + const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE }); + return dt >= payPeriod.week2.start && dt <= payPeriod.week2.end; + }); + + const week1Hours = calculateHoursFromPunches(week1Punches); + const week2Hours = calculateHoursFromPunches(week2Punches); + + // Calculate overtime per week (anything over 40 hours) + const week1Overtime = Math.max(0, week1Hours.hours - STANDARD_WEEKLY_HOURS); + const week2Overtime = Math.max(0, week2Hours.hours - STANDARD_WEEKLY_HOURS); + const week1Regular = week1Hours.hours - week1Overtime; + const week2Regular = week2Hours.hours - week2Overtime; + + const employeeTotal = week1Hours.hours + week2Hours.hours; + const employeeBreaks = week1Hours.breakHours + week2Hours.breakHours; + const employeeOvertime = week1Overtime + week2Overtime; + const employeeRegular = employeeTotal - employeeOvertime; + + totalHours += employeeTotal; + totalBreakHours += employeeBreaks; + totalOvertimeHours += employeeOvertime; + totalRegularHours += employeeRegular; + week1TotalHours += week1Hours.hours; + week1TotalOvertime += week1Overtime; + week2TotalHours += week2Hours.hours; + week2TotalOvertime += week2Overtime; + + employeeResults.push({ + employeeId: employeeData.employeeId, + name: `${employeeData.firstname} ${employeeData.lastname}`.trim() || `Employee ${employeeData.employeeId}`, + week1Hours: week1Hours.hours, + week1BreakHours: week1Hours.breakHours, + week1Overtime, + week1Regular, + week2Hours: week2Hours.hours, + week2BreakHours: week2Hours.breakHours, + week2Overtime, + week2Regular, + totalHours: employeeTotal, + totalBreakHours: employeeBreaks, + overtimeHours: employeeOvertime, + regularHours: employeeRegular, + }); + }); + + // Sort by total hours descending + employeeResults.sort((a, b) => b.totalHours - a.totalHours); + + return { + byEmployee: employeeResults, + totals: { + hours: totalHours, + breakHours: totalBreakHours, + overtimeHours: totalOvertimeHours, + regularHours: totalRegularHours, + activeEmployees: employeeResults.filter(e => e.totalHours > 0).length, + }, + byWeek: [ + { + week: 1, + start: payPeriod.week1.start.toISODate(), + end: payPeriod.week1.end.toISODate(), + hours: week1TotalHours, + overtime: week1TotalOvertime, + regular: week1TotalHours - week1TotalOvertime, + }, + { + week: 2, + start: payPeriod.week2.start.toISODate(), + end: payPeriod.week2.end.toISODate(), + hours: week2TotalHours, + overtime: week2TotalOvertime, + regular: week2TotalHours - week2TotalOvertime, + }, + ], + }; +} + +/** + * Calculate hours from a set of punches + */ +function calculateHoursFromPunches(punches) { + let hours = 0; + let breakHours = 0; + let currentIn = null; + let breakStart = null; + + punches.forEach(punch => { + const punchTime = new Date(punch.TimeStamp); + + switch (punch.PunchType) { + case PUNCH_TYPES.IN: + currentIn = punchTime; + break; + case PUNCH_TYPES.OUT: + if (currentIn) { + hours += (punchTime - currentIn) / (1000 * 60 * 60); + currentIn = null; + } + break; + case PUNCH_TYPES.BREAK_START: + breakStart = punchTime; + break; + case PUNCH_TYPES.BREAK_END: + if (breakStart) { + breakHours += (punchTime - breakStart) / (1000 * 60 * 60); + breakStart = null; + } + break; + } + }); + + return { hours, breakHours }; +} + +/** + * Calculate FTE for a pay period (based on 80 hours = 1 FTE for 2-week period) + */ +function calculateFTE(totalHours) { + const fullTimePeriodHours = STANDARD_WEEKLY_HOURS * 2; // 80 hours for 2 weeks + return totalHours / fullTimePeriodHours; +} + +// Main payroll metrics endpoint +router.get('/', async (req, res) => { + const startTime = Date.now(); + console.log(`[PAYROLL-METRICS] Starting request`); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Request timeout after 30 seconds')), 30000); + }); + + try { + const mainOperation = async () => { + const { payPeriodStart, navigate } = req.query; + + let payPeriod; + + if (payPeriodStart) { + // Parse the provided start date + const startDate = DateTime.fromISO(payPeriodStart, { zone: TIMEZONE }); + if (!startDate.isValid) { + return res.status(400).json({ error: 'Invalid payPeriodStart date format' }); + } + payPeriod = getPayPeriodForDate(startDate); + } else { + // Default to current pay period + payPeriod = getCurrentPayPeriod(); + } + + // Handle navigation if requested + if (navigate) { + const offset = parseInt(navigate, 10); + if (!isNaN(offset)) { + payPeriod = navigatePayPeriod(payPeriod.start, offset); + } + } + + console.log(`[PAYROLL-METRICS] Getting DB connection...`); + const { connection, release } = await getDbConnection(); + console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`); + + // Build query for the pay period + const periodStart = payPeriod.start.toJSDate(); + const periodEnd = payPeriod.end.toJSDate(); + + const timeclockQuery = ` + SELECT + tc.EmployeeID, + tc.TimeStamp, + tc.PunchType, + e.firstname, + e.lastname + FROM timeclock tc + LEFT JOIN employees e ON tc.EmployeeID = e.employeeid + WHERE tc.TimeStamp >= ? AND tc.TimeStamp <= ? + AND e.hidden = 0 + AND e.disabled = 0 + ORDER BY tc.EmployeeID, tc.TimeStamp + `; + + const [timeclockRows] = await connection.execute(timeclockQuery, [periodStart, periodEnd]); + + // Calculate hours with week breakdown + const hoursData = calculateHoursByWeek(timeclockRows, payPeriod); + + // Calculate FTE + const fte = calculateFTE(hoursData.totals.hours); + const activeEmployees = hoursData.totals.activeEmployees; + const avgHoursPerEmployee = activeEmployees > 0 ? hoursData.totals.hours / activeEmployees : 0; + + // Get previous pay period data for comparison + const prevPayPeriod = navigatePayPeriod(payPeriod.start, -1); + const [prevTimeclockRows] = await connection.execute(timeclockQuery, [ + prevPayPeriod.start.toJSDate(), + prevPayPeriod.end.toJSDate(), + ]); + + const prevHoursData = calculateHoursByWeek(prevTimeclockRows, prevPayPeriod); + const prevFte = calculateFTE(prevHoursData.totals.hours); + + // Calculate comparisons + const comparison = { + hours: calculateComparison(hoursData.totals.hours, prevHoursData.totals.hours), + overtimeHours: calculateComparison(hoursData.totals.overtimeHours, prevHoursData.totals.overtimeHours), + fte: calculateComparison(fte, prevFte), + activeEmployees: calculateComparison(hoursData.totals.activeEmployees, prevHoursData.totals.activeEmployees), + }; + + const response = { + payPeriod: { + start: payPeriod.start.toISODate(), + end: payPeriod.end.toISODate(), + label: formatPayPeriodLabel(payPeriod), + week1: { + start: payPeriod.week1.start.toISODate(), + end: payPeriod.week1.end.toISODate(), + label: formatWeekLabel(payPeriod.week1), + }, + week2: { + start: payPeriod.week2.start.toISODate(), + end: payPeriod.week2.end.toISODate(), + label: formatWeekLabel(payPeriod.week2), + }, + isCurrent: isCurrentPayPeriod(payPeriod), + }, + totals: { + hours: hoursData.totals.hours, + breakHours: hoursData.totals.breakHours, + overtimeHours: hoursData.totals.overtimeHours, + regularHours: hoursData.totals.regularHours, + activeEmployees, + fte, + avgHoursPerEmployee, + }, + previousTotals: { + hours: prevHoursData.totals.hours, + overtimeHours: prevHoursData.totals.overtimeHours, + activeEmployees: prevHoursData.totals.activeEmployees, + fte: prevFte, + }, + comparison, + byEmployee: hoursData.byEmployee, + byWeek: hoursData.byWeek, + }; + + return { response, release }; + }; + + let result; + try { + result = await Promise.race([mainOperation(), timeoutPromise]); + } catch (error) { + if (error.message.includes('timeout')) { + console.log(`[PAYROLL-METRICS] Request timed out in ${Date.now() - startTime}ms`); + throw error; + } + throw error; + } + + const { response, release } = result; + + if (release) release(); + + console.log(`[PAYROLL-METRICS] Request completed in ${Date.now() - startTime}ms`); + res.json(response); + + } catch (error) { + console.error('Error in /payroll-metrics:', error); + console.log(`[PAYROLL-METRICS] Request failed in ${Date.now() - startTime}ms`); + res.status(500).json({ error: error.message }); + } +}); + +// Get pay period info endpoint (for navigation without full data) +router.get('/period-info', async (req, res) => { + try { + const { payPeriodStart, navigate } = req.query; + + let payPeriod; + + if (payPeriodStart) { + const startDate = DateTime.fromISO(payPeriodStart, { zone: TIMEZONE }); + if (!startDate.isValid) { + return res.status(400).json({ error: 'Invalid payPeriodStart date format' }); + } + payPeriod = getPayPeriodForDate(startDate); + } else { + payPeriod = getCurrentPayPeriod(); + } + + if (navigate) { + const offset = parseInt(navigate, 10); + if (!isNaN(offset)) { + payPeriod = navigatePayPeriod(payPeriod.start, offset); + } + } + + res.json({ + payPeriod: { + start: payPeriod.start.toISODate(), + end: payPeriod.end.toISODate(), + label: formatPayPeriodLabel(payPeriod), + week1: { + start: payPeriod.week1.start.toISODate(), + end: payPeriod.week1.end.toISODate(), + label: formatWeekLabel(payPeriod.week1), + }, + week2: { + start: payPeriod.week2.start.toISODate(), + end: payPeriod.week2.end.toISODate(), + label: formatWeekLabel(payPeriod.week2), + }, + isCurrent: isCurrentPayPeriod(payPeriod), + }, + }); + } catch (error) { + console.error('Error in /payroll-metrics/period-info:', error); + res.status(500).json({ error: error.message }); + } +}); + +// Health check +router.get('/health', async (req, res) => { + try { + const { connection, release } = await getDbConnection(); + await connection.execute('SELECT 1 as test'); + release(); + + res.json({ + status: 'healthy', + timestamp: new Date().toISOString(), + pool: getPoolStatus(), + }); + } catch (error) { + res.status(500).json({ + status: 'unhealthy', + timestamp: new Date().toISOString(), + error: error.message, + }); + } +}); + +// Helper functions +function calculateComparison(currentValue, previousValue) { + if (typeof previousValue !== 'number') { + return { absolute: null, percentage: null }; + } + + const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null; + const percentage = + absolute !== null && previousValue !== 0 + ? (absolute / Math.abs(previousValue)) * 100 + : null; + + return { absolute, percentage }; +} + +function formatPayPeriodLabel(payPeriod) { + const startStr = payPeriod.start.toFormat('MMM d'); + const endStr = payPeriod.end.toFormat('MMM d, yyyy'); + return `${startStr} – ${endStr}`; +} + +function formatWeekLabel(week) { + const startStr = week.start.toFormat('MMM d'); + const endStr = week.end.toFormat('MMM d'); + return `${startStr} – ${endStr}`; +} + +function isCurrentPayPeriod(payPeriod) { + const now = DateTime.now().setZone(TIMEZONE); + return now >= payPeriod.start && now <= payPeriod.end; +} + +module.exports = router; diff --git a/inventory-server/dashboard/acot-server/server.js b/inventory-server/dashboard/acot-server/server.js index 4a70601..1a53965 100644 --- a/inventory-server/dashboard/acot-server/server.js +++ b/inventory-server/dashboard/acot-server/server.js @@ -49,6 +49,9 @@ app.get('/health', (req, res) => { app.use('/api/acot/test', require('./routes/test')); app.use('/api/acot/events', require('./routes/events')); app.use('/api/acot/discounts', require('./routes/discounts')); +app.use('/api/acot/employee-metrics', require('./routes/employee-metrics')); +app.use('/api/acot/payroll-metrics', require('./routes/payroll-metrics')); +app.use('/api/acot/operations-metrics', require('./routes/operations-metrics')); // Error handling middleware app.use((err, req, res, next) => { diff --git a/inventory/src/components/dashboard/OperationsMetrics.tsx b/inventory/src/components/dashboard/OperationsMetrics.tsx new file mode 100644 index 0000000..8fdee4c --- /dev/null +++ b/inventory/src/components/dashboard/OperationsMetrics.tsx @@ -0,0 +1,913 @@ +import { useEffect, useMemo, useState } from "react"; +import { acotService } from "@/services/dashboard/acotService"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Separator } from "@/components/ui/separator"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Area, + CartesianGrid, + ComposedChart, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TooltipProps } from "recharts"; +import { Package, Truck, Gauge, TrendingUp } from "lucide-react"; +import PeriodSelectionPopover, { + type QuickPreset, +} from "@/components/dashboard/PeriodSelectionPopover"; +import type { CustomPeriod, NaturalLanguagePeriodResult } from "@/utils/naturalLanguagePeriod"; +import { CARD_STYLES } from "@/lib/dashboard/designTokens"; +import { + DashboardSectionHeader, + DashboardStatCard, + DashboardStatCardSkeleton, + ChartSkeleton, + DashboardEmptyState, + DashboardErrorState, + TOOLTIP_STYLES, + METRIC_COLORS, +} from "@/components/dashboard/shared"; + +type ComparisonValue = { + absolute: number | null; + percentage: number | null; +}; + +type OperationsTotals = { + ordersPicked: number; + piecesPicked: number; + ticketCount: number; + pickingHours: number; + ordersShipped: number; + piecesShipped: number; + ordersPerHour: number; + piecesPerHour: number; + avgPickingSpeed: number; +}; + +type OperationsComparison = { + ordersPicked?: ComparisonValue; + piecesPicked?: ComparisonValue; + ordersShipped?: ComparisonValue; + piecesShipped?: ComparisonValue; + ordersPerHour?: ComparisonValue; + piecesPerHour?: ComparisonValue; +}; + +type EmployeePickingEntry = { + employeeId: number; + name: string; + ticketCount: number; + ordersPicked: number; + piecesPicked: number; + pickingHours: number; + avgPickingSpeed: number | null; +}; + +type EmployeeShippingEntry = { + employeeId: number; + name: string; + ordersShipped: number; + piecesShipped: number; +}; + +type TrendPoint = { + date: string; + timestamp: string; + ordersPicked: number; + piecesPicked: number; + ordersShipped: number; + piecesShipped: number; +}; + +type OperationsMetricsResponse = { + dateRange?: { label?: string }; + totals: OperationsTotals; + previousTotals?: OperationsTotals | null; + comparison?: OperationsComparison | null; + byEmployee: { + picking: EmployeePickingEntry[]; + shipping: EmployeeShippingEntry[]; + }; + trend: TrendPoint[]; +}; + +type ChartSeriesKey = "ordersPicked" | "piecesPicked" | "ordersShipped" | "piecesShipped"; + +type GroupByOption = "day" | "week" | "month"; + +type ChartPoint = { + label: string; + timestamp: string | null; + ordersPicked: number | null; + piecesPicked: number | null; + ordersShipped: number | null; + piecesShipped: number | null; + tooltipLabel: string; +}; + +const chartColors: Record = { + ordersPicked: METRIC_COLORS.orders, + piecesPicked: METRIC_COLORS.aov, + ordersShipped: METRIC_COLORS.profit, + piecesShipped: METRIC_COLORS.secondary, +}; + +const SERIES_LABELS: Record = { + ordersPicked: "Orders Picked", + piecesPicked: "Pieces Picked", + ordersShipped: "Orders Shipped", + piecesShipped: "Pieces Shipped", +}; + +const SERIES_DEFINITIONS: Array<{ + key: ChartSeriesKey; + label: string; +}> = [ + { key: "ordersPicked", label: SERIES_LABELS.ordersPicked }, + { key: "piecesPicked", label: SERIES_LABELS.piecesPicked }, + { key: "ordersShipped", label: SERIES_LABELS.ordersShipped }, + { key: "piecesShipped", label: SERIES_LABELS.piecesShipped }, +]; + +const GROUP_BY_CHOICES: Array<{ value: GroupByOption; label: string }> = [ + { value: "day", label: "Days" }, + { value: "week", label: "Weeks" }, + { value: "month", label: "Months" }, +]; + +const MONTHS = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +]; + +const MONTH_COUNT_LIMIT = 999; +const QUARTER_COUNT_LIMIT = 999; +const YEAR_COUNT_LIMIT = 999; + +const formatMonthLabel = (year: number, monthIndex: number) => `${MONTHS[monthIndex]} ${year}`; + +const formatQuarterLabel = (year: number, quarterIndex: number) => `Q${quarterIndex + 1} ${year}`; + +function formatPeriodRangeLabel(period: CustomPeriod): string { + const range = computePeriodRange(period); + if (!range) return ""; + + const start = range.start; + const end = range.end; + + if (period.type === "month") { + const startLabel = formatMonthLabel(start.getFullYear(), start.getMonth()); + const endLabel = formatMonthLabel(end.getFullYear(), end.getMonth()); + return period.count === 1 ? startLabel : `${startLabel} – ${endLabel}`; + } + + if (period.type === "quarter") { + const startQuarter = Math.floor(start.getMonth() / 3); + const endQuarter = Math.floor(end.getMonth() / 3); + const startLabel = formatQuarterLabel(start.getFullYear(), startQuarter); + const endLabel = formatQuarterLabel(end.getFullYear(), endQuarter); + return period.count === 1 ? startLabel : `${startLabel} – ${endLabel}`; + } + + const startYear = start.getFullYear(); + const endYear = end.getFullYear(); + return period.count === 1 ? `${startYear}` : `${startYear} – ${endYear}`; +} + +const formatNumber = (value: number, decimals = 0) => { + if (!Number.isFinite(value)) return "0"; + return value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +}; + +const formatHours = (value: number) => { + if (!Number.isFinite(value)) return "0h"; + return `${value.toFixed(1)}h`; +}; + +const ensureValidCustomPeriod = (period: CustomPeriod): CustomPeriod => { + if (period.count < 1) { + return { ...period, count: 1 }; + } + + switch (period.type) { + case "month": + return { + ...period, + startMonth: Math.min(Math.max(period.startMonth, 0), 11), + count: Math.min(period.count, MONTH_COUNT_LIMIT), + }; + case "quarter": + return { + ...period, + startQuarter: Math.min(Math.max(period.startQuarter, 0), 3), + count: Math.min(period.count, QUARTER_COUNT_LIMIT), + }; + case "year": + default: + return { + ...period, + count: Math.min(period.count, YEAR_COUNT_LIMIT), + }; + } +}; + +function computePeriodRange(period: CustomPeriod): { start: Date; end: Date } | null { + const safePeriod = ensureValidCustomPeriod(period); + let start: Date; + + if (safePeriod.type === "month") { + start = new Date(safePeriod.startYear, safePeriod.startMonth, 1, 0, 0, 0, 0); + const endExclusive = new Date(start); + endExclusive.setMonth(endExclusive.getMonth() + safePeriod.count); + endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1); + return { start, end: endExclusive }; + } + + if (safePeriod.type === "quarter") { + const startMonth = safePeriod.startQuarter * 3; + start = new Date(safePeriod.startYear, startMonth, 1, 0, 0, 0, 0); + const endExclusive = new Date(start); + endExclusive.setMonth(endExclusive.getMonth() + safePeriod.count * 3); + endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1); + return { start, end: endExclusive }; + } + + start = new Date(safePeriod.startYear, 0, 1, 0, 0, 0, 0); + const endExclusive = new Date(start); + endExclusive.setFullYear(endExclusive.getFullYear() + safePeriod.count); + endExclusive.setMilliseconds(endExclusive.getMilliseconds() - 1); + return { start, end: endExclusive }; +} + +const OperationsMetrics = () => { + const currentDate = useMemo(() => new Date(), []); + const currentYear = currentDate.getFullYear(); + + const [customPeriod, setCustomPeriod] = useState({ + type: "month", + startYear: currentYear, + startMonth: currentDate.getMonth(), + count: 1, + }); + const [isLast30DaysMode, setIsLast30DaysMode] = useState(true); + const [isPeriodPopoverOpen, setIsPeriodPopoverOpen] = useState(false); + const [metrics, setMetrics] = useState>({ + ordersPicked: true, + piecesPicked: false, + ordersShipped: true, + piecesShipped: false, + }); + const [groupBy, setGroupBy] = useState("day"); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const selectedRange = useMemo(() => { + if (isLast30DaysMode) { + const end = new Date(currentDate); + const start = new Date(currentDate); + start.setHours(0, 0, 0, 0); + end.setHours(23, 59, 59, 999); + start.setDate(start.getDate() - 29); + return { start, end }; + } + return computePeriodRange(customPeriod); + }, [isLast30DaysMode, customPeriod, currentDate]); + + const effectiveRangeEnd = useMemo(() => { + if (!selectedRange) return null; + const rangeEndMs = selectedRange.end.getTime(); + const currentMs = currentDate.getTime(); + const startMs = selectedRange.start.getTime(); + const clampedMs = Math.min(rangeEndMs, currentMs); + const safeEndMs = clampedMs < startMs ? startMs : clampedMs; + return new Date(safeEndMs); + }, [selectedRange, currentDate]); + + const requestRange = useMemo(() => { + if (!selectedRange) return null; + const end = effectiveRangeEnd ?? selectedRange.end; + return { + start: new Date(selectedRange.start), + end: new Date(end), + }; + }, [selectedRange, effectiveRangeEnd]); + + useEffect(() => { + let cancelled = false; + + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + const params: Record = {}; + + if (isLast30DaysMode) { + params.timeRange = "last30days"; + } else { + if (!selectedRange || !requestRange) { + setData(null); + return; + } + params.timeRange = "custom"; + params.startDate = requestRange.start.toISOString(); + params.endDate = requestRange.end.toISOString(); + } + + // @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type + const response = (await acotService.getOperationsMetrics(params)) as OperationsMetricsResponse; + if (!cancelled) { + setData(response); + } + } catch (err: unknown) { + if (!cancelled) { + let message = "Failed to load operations metrics"; + if (typeof err === "object" && err !== null) { + const maybeError = err as { response?: { data?: { error?: unknown } }; message?: unknown }; + const responseError = maybeError.response?.data?.error; + if (typeof responseError === "string" && responseError.trim().length > 0) { + message = responseError; + } else if (typeof maybeError.message === "string") { + message = maybeError.message; + } + } + setError(message); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void fetchData(); + return () => { cancelled = true; }; + }, [isLast30DaysMode, selectedRange, requestRange]); + + const cards = useMemo(() => { + if (!data?.totals) return []; + + const totals = data.totals; + const comparison = data.comparison ?? {}; + + return [ + { + key: "ordersPicked", + title: "Orders Picked", + value: formatNumber(totals.ordersPicked), + description: `${formatNumber(totals.piecesPicked)} pieces`, + trendValue: comparison.ordersPicked?.percentage, + iconColor: "blue" as const, + tooltip: "Total distinct orders picked (ship-together groups count as 1).", + }, + { + key: "ordersShipped", + title: "Orders Shipped", + value: formatNumber(totals.ordersShipped), + description: `${formatNumber(totals.piecesShipped)} pieces`, + trendValue: comparison.ordersShipped?.percentage, + iconColor: "emerald" as const, + tooltip: "Total orders shipped (ship-together groups count as 1).", + }, + { + key: "productivity", + title: "Productivity", + value: `${formatNumber(totals.ordersPerHour, 1)}/h`, + description: `${formatNumber(totals.piecesPerHour, 1)} pieces/hour`, + trendValue: comparison.ordersPerHour?.percentage, + iconColor: "purple" as const, + tooltip: "Orders and pieces picked per picking hour.", + }, + { + key: "pickingSpeed", + title: "Picking Speed", + value: `${formatNumber(totals.avgPickingSpeed, 1)}/h`, + description: `${formatHours(totals.pickingHours)} picking time`, + iconColor: "orange" as const, + tooltip: "Average pieces picked per hour while actively picking.", + }, + ]; + }, [data]); + + const chartData = useMemo(() => { + if (!data?.trend?.length) return []; + + const groupedData = new Map(); + + data.trend.forEach((point) => { + const date = new Date(point.timestamp); + let key: string; + let label: string; + let tooltipLabel: string; + + switch (groupBy) { + case "week": { + const weekStart = new Date(date); + weekStart.setDate(date.getDate() - date.getDay()); + key = weekStart.toISOString().split("T")[0]; + label = weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + tooltipLabel = `Week of ${weekStart.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`; + break; + } + case "month": { + key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + label = date.toLocaleDateString("en-US", { month: "short", year: "numeric" }); + tooltipLabel = date.toLocaleDateString("en-US", { month: "long", year: "numeric" }); + break; + } + default: { + key = point.date; + label = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + tooltipLabel = date.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", year: "numeric" }); + } + } + + const existing = groupedData.get(key); + if (existing) { + existing.ordersPicked += point.ordersPicked || 0; + existing.piecesPicked += point.piecesPicked || 0; + existing.ordersShipped += point.ordersShipped || 0; + existing.piecesShipped += point.piecesShipped || 0; + } else { + groupedData.set(key, { + label, + tooltipLabel, + timestamp: point.timestamp, + ordersPicked: point.ordersPicked || 0, + piecesPicked: point.piecesPicked || 0, + ordersShipped: point.ordersShipped || 0, + piecesShipped: point.piecesShipped || 0, + }); + } + }); + + return Array.from(groupedData.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([, group]) => ({ + label: group.label, + timestamp: group.timestamp, + ordersPicked: group.ordersPicked, + piecesPicked: group.piecesPicked, + ordersShipped: group.ordersShipped, + piecesShipped: group.piecesShipped, + tooltipLabel: group.tooltipLabel, + })); + }, [data, groupBy]); + + const selectedRangeLabel = useMemo(() => { + if (isLast30DaysMode) return "Last 30 Days"; + const label = formatPeriodRangeLabel(customPeriod); + if (!label) return ""; + + if (!selectedRange || !effectiveRangeEnd) return label; + + const isPartial = effectiveRangeEnd.getTime() < selectedRange.end.getTime(); + if (!isPartial) return label; + + const partialLabel = effectiveRangeEnd.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + + return `${label} (through ${partialLabel})`; + }, [isLast30DaysMode, customPeriod, selectedRange, effectiveRangeEnd]); + + const hasActiveMetrics = useMemo(() => Object.values(metrics).some(Boolean), [metrics]); + const hasData = chartData.length > 0; + + const handleGroupByChange = (value: string) => { + setGroupBy(value as GroupByOption); + }; + + const toggleMetric = (series: ChartSeriesKey) => { + setMetrics((prev) => ({ + ...prev, + [series]: !prev[series], + })); + }; + + const handleNaturalLanguageResult = (result: NaturalLanguagePeriodResult) => { + if (result === "last30days") { + setIsLast30DaysMode(true); + return; + } + if (result) { + setIsLast30DaysMode(false); + setCustomPeriod(result); + } + }; + + const handleQuickPeriod = (preset: QuickPreset) => { + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth(); + const quarter = Math.floor(month / 3); + + switch (preset) { + case "last30days": + setIsLast30DaysMode(true); + break; + case "thisMonth": + setIsLast30DaysMode(false); + setCustomPeriod({ type: "month", startYear: year, startMonth: month, count: 1 }); + break; + case "lastMonth": + setIsLast30DaysMode(false); + const lastMonth = month === 0 ? 11 : month - 1; + const lastMonthYear = month === 0 ? year - 1 : year; + setCustomPeriod({ type: "month", startYear: lastMonthYear, startMonth: lastMonth, count: 1 }); + break; + case "thisQuarter": + setIsLast30DaysMode(false); + setCustomPeriod({ type: "quarter", startYear: year, startQuarter: quarter, count: 1 }); + break; + case "lastQuarter": + setIsLast30DaysMode(false); + const lastQuarter = quarter === 0 ? 3 : quarter - 1; + const lastQuarterYear = quarter === 0 ? year - 1 : year; + setCustomPeriod({ type: "quarter", startYear: lastQuarterYear, startQuarter: lastQuarter, count: 1 }); + break; + case "thisYear": + setIsLast30DaysMode(false); + setCustomPeriod({ type: "year", startYear: year, count: 1 }); + break; + default: + break; + } + }; + + const headerActions = !error ? ( + <> + + + + + + + + Operations Details + + +
+
+

Picking by Employee

+
+ + + + Employee + Tickets + Orders + Pieces + Hours + Speed + + + + {data?.byEmployee?.picking?.map((emp) => ( + + {emp.name} + {formatNumber(emp.ticketCount)} + {formatNumber(emp.ordersPicked)} + {formatNumber(emp.piecesPicked)} + {formatHours(emp.pickingHours || 0)} + + {emp.avgPickingSpeed != null ? `${formatNumber(emp.avgPickingSpeed, 1)}/h` : "—"} + + + ))} + +
+
+
+ + {data?.byEmployee?.shipping && data.byEmployee.shipping.length > 0 && ( +
+

Shipping by Employee

+
+ + + + Employee + Orders + Pieces + + + + {data.byEmployee.shipping.map((emp) => ( + + {emp.name} + {formatNumber(emp.ordersShipped)} + {formatNumber(emp.piecesShipped)} + + ))} + +
+
+
+ )} +
+
+
+ + + + ) : null; + + return ( + + + + + {!error && ( + loading ? ( + + ) : ( + cards.length > 0 && + ) + )} + + {!error && ( +
+
+ {SERIES_DEFINITIONS.map((series) => ( + + ))} +
+ + + + +
+
Group:
+ +
+
+ )} + + {loading ? ( + + ) : error ? ( + + ) : !hasData ? ( + + ) : ( +
+ {!hasActiveMetrics ? ( + + ) : ( + + + + + + + + + + + formatNumber(value)} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + } /> + SERIES_LABELS[value as ChartSeriesKey] ?? value} /> + + {metrics.ordersPicked && ( + + )} + {metrics.ordersShipped && ( + + )} + {metrics.piecesPicked && ( + + )} + {metrics.piecesShipped && ( + + )} + + + )} +
+ )} +
+
+ ); +}; + +type OperationsStatCardConfig = { + key: string; + title: string; + value: string; + description?: string; + trendValue?: number | null; + trendInverted?: boolean; + iconColor: "blue" | "orange" | "emerald" | "purple" | "cyan" | "amber"; + tooltip?: string; +}; + +const ICON_MAP = { + ordersPicked: Package, + ordersShipped: Truck, + productivity: Gauge, + pickingSpeed: TrendingUp, +} as const; + +function OperationsStatGrid({ cards }: { cards: OperationsStatCardConfig[] }) { + return ( +
+ {cards.map((card) => ( + + ))} +
+ ); +} + +function SkeletonStats() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ); +} + +const OperationsTooltip = ({ active, payload, label }: TooltipProps) => { + if (!active || !payload?.length) return null; + + const basePoint = payload[0]?.payload as ChartPoint | undefined; + const resolvedLabel = basePoint?.tooltipLabel ?? label; + + const desiredOrder: ChartSeriesKey[] = ["ordersPicked", "piecesPicked", "ordersShipped", "piecesShipped"]; + const payloadMap = new Map(payload.map((entry) => [entry.dataKey as ChartSeriesKey, entry])); + const orderedPayload = desiredOrder + .map((key) => payloadMap.get(key)) + .filter((entry): entry is (typeof payload)[0] => entry !== undefined); + + return ( +
+

{resolvedLabel}

+
+ {orderedPayload.map((entry, index) => { + const key = (entry.dataKey ?? "") as ChartSeriesKey; + const rawValue = entry.value; + const formattedValue = rawValue != null ? formatNumber(rawValue as number) : "—"; + + return ( +
+
+ + + {SERIES_LABELS[key] ?? entry.name ?? key} + +
+ {formattedValue} +
+ ); + })} +
+
+ ); +}; + +export default OperationsMetrics; diff --git a/inventory/src/components/dashboard/PayrollMetrics.tsx b/inventory/src/components/dashboard/PayrollMetrics.tsx new file mode 100644 index 0000000..5d8e3e3 --- /dev/null +++ b/inventory/src/components/dashboard/PayrollMetrics.tsx @@ -0,0 +1,558 @@ +import { useEffect, useMemo, useState } from "react"; +import { acotService } from "@/services/dashboard/acotService"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Bar, + BarChart, + CartesianGrid, + Cell, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import type { TooltipProps } from "recharts"; +import { Clock, Users, AlertTriangle, ChevronLeft, ChevronRight, Calendar } from "lucide-react"; +import { CARD_STYLES } from "@/lib/dashboard/designTokens"; +import { + DashboardSectionHeader, + DashboardStatCard, + DashboardStatCardSkeleton, + ChartSkeleton, + DashboardEmptyState, + DashboardErrorState, + TOOLTIP_STYLES, + METRIC_COLORS, +} from "@/components/dashboard/shared"; + +type ComparisonValue = { + absolute: number | null; + percentage: number | null; +}; + +type PayPeriodWeek = { + start: string; + end: string; + label: string; +}; + +type PayPeriod = { + start: string; + end: string; + label: string; + week1: PayPeriodWeek; + week2: PayPeriodWeek; + isCurrent: boolean; +}; + +type PayrollTotals = { + hours: number; + breakHours: number; + overtimeHours: number; + regularHours: number; + activeEmployees: number; + fte: number; + avgHoursPerEmployee: number; +}; + +type PayrollComparison = { + hours?: ComparisonValue; + overtimeHours?: ComparisonValue; + fte?: ComparisonValue; + activeEmployees?: ComparisonValue; +}; + +type EmployeePayrollEntry = { + employeeId: number; + name: string; + week1Hours: number; + week1BreakHours: number; + week1Overtime: number; + week1Regular: number; + week2Hours: number; + week2BreakHours: number; + week2Overtime: number; + week2Regular: number; + totalHours: number; + totalBreakHours: number; + overtimeHours: number; + regularHours: number; +}; + +type WeekSummary = { + week: number; + start: string; + end: string; + hours: number; + overtime: number; + regular: number; +}; + +type PayrollMetricsResponse = { + payPeriod: PayPeriod; + totals: PayrollTotals; + previousTotals?: PayrollTotals | null; + comparison?: PayrollComparison | null; + byEmployee: EmployeePayrollEntry[]; + byWeek: WeekSummary[]; +}; + +const chartColors = { + regular: METRIC_COLORS.orders, + overtime: METRIC_COLORS.expense, +}; + +const formatNumber = (value: number, decimals = 0) => { + if (!Number.isFinite(value)) return "0"; + return value.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); +}; + +const formatHours = (value: number) => { + if (!Number.isFinite(value)) return "0h"; + return `${value.toFixed(1)}h`; +}; + +const PayrollMetrics = () => { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPayPeriodStart, setCurrentPayPeriodStart] = useState(null); + + // Fetch data + useEffect(() => { + let cancelled = false; + + const fetchData = async () => { + setLoading(true); + setError(null); + + try { + const params: Record = {}; + if (currentPayPeriodStart) { + params.payPeriodStart = currentPayPeriodStart; + } + + // @ts-expect-error - acotService is a JS file, TypeScript can't infer the param type + const response = (await acotService.getPayrollMetrics(params)) as PayrollMetricsResponse; + if (!cancelled) { + setData(response); + // Update the current pay period start if not set (first load) + if (!currentPayPeriodStart && response.payPeriod?.start) { + setCurrentPayPeriodStart(response.payPeriod.start); + } + } + } catch (err: unknown) { + if (!cancelled) { + let message = "Failed to load payroll metrics"; + if (typeof err === "object" && err !== null) { + const maybeError = err as { response?: { data?: { error?: unknown } }; message?: unknown }; + const responseError = maybeError.response?.data?.error; + if (typeof responseError === "string" && responseError.trim().length > 0) { + message = responseError; + } else if (typeof maybeError.message === "string") { + message = maybeError.message; + } + } + setError(message); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void fetchData(); + return () => { cancelled = true; }; + }, [currentPayPeriodStart]); + + const navigatePeriod = (direction: "prev" | "next") => { + if (!data?.payPeriod?.start) return; + + // Calculate the new pay period start by adding/subtracting 14 days + const currentStart = new Date(data.payPeriod.start); + const offset = direction === "prev" ? -14 : 14; + currentStart.setDate(currentStart.getDate() + offset); + setCurrentPayPeriodStart(currentStart.toISOString().split("T")[0]); + }; + + const goToCurrentPeriod = () => { + setCurrentPayPeriodStart(null); // null triggers loading current period + }; + + const cards = useMemo(() => { + if (!data?.totals) return []; + + const totals = data.totals; + const comparison = data.comparison ?? {}; + + return [ + { + key: "hours", + title: "Total Hours", + value: formatHours(totals.hours), + description: `${formatHours(totals.regularHours)} regular`, + trendValue: comparison.hours?.percentage, + iconColor: "blue" as const, + tooltip: "Total hours worked by all employees in this pay period.", + }, + { + key: "overtime", + title: "Overtime", + value: formatHours(totals.overtimeHours), + description: totals.overtimeHours > 0 + ? `${formatNumber((totals.overtimeHours / totals.hours) * 100, 1)}% of total` + : "No overtime", + trendValue: comparison.overtimeHours?.percentage, + trendInverted: true, + iconColor: totals.overtimeHours > 0 ? "orange" as const : "emerald" as const, + tooltip: "Hours exceeding 40 per employee per week.", + }, + { + key: "fte", + title: "FTE", + value: formatNumber(totals.fte, 2), + description: `${formatNumber(totals.activeEmployees)} employees`, + trendValue: comparison.fte?.percentage, + iconColor: "emerald" as const, + tooltip: "Full-Time Equivalents (80 hours = 1 FTE for 2-week period).", + }, + { + key: "avgHours", + title: "Avg Hours", + value: formatHours(totals.avgHoursPerEmployee), + description: "Per employee", + iconColor: "purple" as const, + tooltip: "Average hours worked per active employee in this pay period.", + }, + ]; + }, [data]); + + const chartData = useMemo(() => { + if (!data?.byWeek) return []; + + return data.byWeek.map((week) => ({ + name: `Week ${week.week}`, + label: formatWeekRange(week.start, week.end), + regular: week.regular, + overtime: week.overtime, + total: week.hours, + })); + }, [data]); + + const hasData = data?.byWeek && data.byWeek.length > 0; + + const headerActions = !error ? ( +
+ + + + + + + + Employee Hours - {data?.payPeriod?.label} + + +
+
+ + + + Employee + Week 1 + Week 2 + Total + Overtime + + + + {data?.byEmployee?.map((emp) => ( + + {emp.name} + + 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}> + {formatHours(emp.week1Hours)} + {emp.week1Overtime > 0 && ( + + (+{formatHours(emp.week1Overtime)} OT) + + )} + + + + 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}> + {formatHours(emp.week2Hours)} + {emp.week2Overtime > 0 && ( + + (+{formatHours(emp.week2Overtime)} OT) + + )} + + + + {formatHours(emp.totalHours)} + + + {emp.overtimeHours > 0 ? ( + + {formatHours(emp.overtimeHours)} + + ) : ( + + )} + + + ))} + +
+
+
+
+
+ +
+ + + +
+
+ ) : null; + + return ( + + + + + {!error && ( + loading ? ( + + ) : ( + cards.length > 0 && + ) + )} + + {loading ? ( + + ) : error ? ( + + ) : !hasData ? ( + + ) : ( +
+ + + + + `${value}h`} + className="text-xs text-muted-foreground" + tick={{ fill: "currentColor" }} + /> + } /> + + + + {chartData.map((entry, index) => ( + 0 ? chartColors.overtime : chartColors.regular} + /> + ))} + + + +
+ )} + + {!loading && !error && data?.byWeek && data.byWeek.some(w => w.overtime > 0) && ( +
+ + + Overtime detected: {formatHours(data.totals.overtimeHours)} total + ({data.byEmployee?.filter(e => e.overtimeHours > 0).length || 0} employees) + +
+ )} +
+
+ ); +}; + +function formatWeekRange(start: string, end: string): string { + const startDate = new Date(start + "T00:00:00"); + const endDate = new Date(end + "T00:00:00"); + + const startStr = startDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const endStr = endDate.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + + return `${startStr} – ${endStr}`; +} + +type PayrollStatCardConfig = { + key: string; + title: string; + value: string; + description?: string; + trendValue?: number | null; + trendInverted?: boolean; + iconColor: "blue" | "orange" | "emerald" | "purple" | "cyan" | "amber"; + tooltip?: string; +}; + +const ICON_MAP = { + hours: Clock, + overtime: AlertTriangle, + fte: Users, + avgHours: Clock, +} as const; + +function PayrollStatGrid({ cards }: { cards: PayrollStatCardConfig[] }) { + return ( +
+ {cards.map((card) => ( + + ))} +
+ ); +} + +function SkeletonStats() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ); +} + +const PayrollTooltip = ({ active, payload, label }: TooltipProps) => { + if (!active || !payload?.length) return null; + + const regular = payload.find(p => p.dataKey === "regular")?.value as number | undefined; + const overtime = payload.find(p => p.dataKey === "overtime")?.value as number | undefined; + const total = (regular || 0) + (overtime || 0); + + return ( +
+

{label}

+
+
+
+ + Regular Hours +
+ {formatHours(regular || 0)} +
+ {overtime != null && overtime > 0 && ( +
+
+ + Overtime +
+ {formatHours(overtime)} +
+ )} +
+
+ Total +
+ {formatHours(total)} +
+
+
+ ); +}; + +export default PayrollMetrics; diff --git a/inventory/src/components/discount-simulator/ConfigPanel.tsx b/inventory/src/components/discount-simulator/ConfigPanel.tsx index 4b6d473..3fecf44 100644 --- a/inventory/src/components/discount-simulator/ConfigPanel.tsx +++ b/inventory/src/components/discount-simulator/ConfigPanel.tsx @@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator"; +import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, SurchargeConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator"; import { formatNumber } from "@/utils/productUtils"; import { PlusIcon, X } from "lucide-react"; import { Skeleton } from "@/components/ui/skeleton"; @@ -35,6 +35,8 @@ interface ConfigPanelProps { onShippingPromoChange: (update: Partial) => void; shippingTiers: ShippingTierConfig[]; onShippingTiersChange: (tiers: ShippingTierConfig[]) => void; + surcharges: SurchargeConfig[]; + onSurchargesChange: (surcharges: SurchargeConfig[]) => void; merchantFeePercent: number; onMerchantFeeChange: (value: number) => void; fixedCostPerOrder: number; @@ -43,6 +45,7 @@ interface ConfigPanelProps { onCogsCalculationModeChange: (mode: CogsCalculationMode) => void; pointsPerDollar: number; redemptionRate: number; + onRedemptionRateChange: (value: number) => void; pointDollarValue: number; onPointDollarValueChange: (value: number) => void; onConfigInputChange: () => void; @@ -65,6 +68,7 @@ const formatPercent = (value: number) => { }; const generateTierId = () => `tier-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; +const generateSurchargeId = () => `surcharge-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; const parseDateToTimestamp = (value?: string | null): number | undefined => { if (!value) { @@ -101,6 +105,8 @@ export function ConfigPanel({ onShippingPromoChange, shippingTiers, onShippingTiersChange, + surcharges, + onSurchargesChange, merchantFeePercent, onMerchantFeeChange, fixedCostPerOrder, @@ -109,6 +115,7 @@ export function ConfigPanel({ onCogsCalculationModeChange, pointsPerDollar, redemptionRate, + onRedemptionRateChange, pointDollarValue, onPointDollarValueChange, onConfigInputChange, @@ -235,6 +242,93 @@ export function ConfigPanel({ handleFieldBlur(); }, [sortShippingTiers, handleFieldBlur]); + // Surcharge handlers + useEffect(() => { + if (surcharges.length === 0) { + return; + } + + const surchargesMissingIds = surcharges.some((s) => !s.id); + if (!surchargesMissingIds) { + return; + } + + const normalizedSurcharges = surcharges.map((s) => + s.id ? s : { ...s, id: generateSurchargeId() } + ); + onSurchargesChange(normalizedSurcharges); + }, [surcharges, onSurchargesChange]); + + const handleSurchargeUpdate = (index: number, update: Partial) => { + const items = [...surcharges]; + const current = items[index]; + if (!current) { + return; + } + + const surchargeId = current.id ?? generateSurchargeId(); + const merged = { + ...current, + ...update, + id: surchargeId, + }; + + const normalized: SurchargeConfig = { + ...merged, + threshold: Number.isFinite(merged.threshold) ? merged.threshold ?? 0 : 0, + maxThreshold: Number.isFinite(merged.maxThreshold) && (merged.maxThreshold ?? 0) > 0 ? merged.maxThreshold : undefined, + amount: Number.isFinite(merged.amount) ? merged.amount ?? 0 : 0, + }; + + items[index] = normalized; + onSurchargesChange(items); + }; + + const handleSurchargeRemove = (index: number) => { + onConfigInputChange(); + const items = surcharges.filter((_, i) => i !== index); + onSurchargesChange(items); + }; + + const handleSurchargeAdd = () => { + onConfigInputChange(); + const lastThreshold = surcharges[surcharges.length - 1]?.threshold ?? 0; + const items = [ + ...surcharges, + { + threshold: lastThreshold, + target: "shipping" as const, + amount: 0, + id: generateSurchargeId(), + }, + ]; + onSurchargesChange(items); + }; + + const sortSurcharges = useCallback(() => { + if (surcharges.length < 2) { + return; + } + + const originalIds = surcharges.map((s) => s.id); + const sorted = [...surcharges] + .map((s) => ({ + ...s, + threshold: Number.isFinite(s.threshold) ? s.threshold : 0, + amount: Number.isFinite(s.amount) ? s.amount : 0, + })) + .sort((a, b) => a.threshold - b.threshold); + + const orderChanged = sorted.some((s, index) => s.id !== originalIds[index]); + if (orderChanged) { + onSurchargesChange(sorted); + } + }, [surcharges, onSurchargesChange]); + + const handleSurchargeBlur = useCallback(() => { + sortSurcharges(); + handleFieldBlur(); + }, [sortSurcharges, handleFieldBlur]); const sectionTitleClass = "text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-muted-foreground"; const sectionBaseClass = "flex flex-col rounded-md border border-border/60 bg-muted/30 px-3 py-2.5"; @@ -244,10 +338,10 @@ export function ConfigPanel({ const fieldClass = "flex flex-col gap-1"; const labelClass = "text-[0.65rem] uppercase tracking-wide text-muted-foreground"; const fieldRowClass = "flex flex-col gap-2"; - const fieldRowHorizontalClass = "flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-3"; - const compactTriggerClass = "h-8 px-2 text-xs"; - const compactNumberClass = "h-8 px-2 text-sm"; - const compactWideNumberClass = "h-8 px-2 text-sm"; + const fieldRowHorizontalClass = "flex flex-col gap-2 sm:flex-row sm:gap-3"; + const compactTriggerClass = "h-8 px-1.5 text-xs"; + const compactNumberClass = "h-8 px-1.5 text-sm"; + const compactWideNumberClass = "h-8 px-1.5 text-sm"; const metricPillClass = "flex items-center gap-1 rounded border border-border/60 bg-background px-2 py-1 text-[0.68rem] font-medium text-foreground"; const showProductAdjustments = productPromo.type !== "none"; const showShippingAdjustments = shippingPromo.type !== "none"; @@ -255,8 +349,8 @@ export function ConfigPanel({ return ( - -
+ +
@@ -487,7 +581,7 @@ export function ConfigPanel({ return (
+
+
+ Surcharges + +
+ {surcharges.length === 0 ? ( +

Add surcharges to model fees at different order values.

+ ) : ( + +
+
+
Min
+
Max
+
Add To
+
Amount
+ + {surcharges.map((surcharge, index) => { + const surchargeKey = surcharge.id ?? `surcharge-${index}`; + return ( +
+
+ { + onConfigInputChange(); + handleSurchargeUpdate(index, { + threshold: parseNumber(event.target.value, 0), + }); + }} + onBlur={handleSurchargeBlur} + /> +
+
+ { + onConfigInputChange(); + const val = event.target.value; + handleSurchargeUpdate(index, { + maxThreshold: val === '' ? undefined : parseNumber(val, 0), + }); + }} + onBlur={handleSurchargeBlur} + /> +
+
+ +
+
+ { + onConfigInputChange(); + handleSurchargeUpdate(index, { amount: parseNumber(event.target.value, 0) }); + }} + onBlur={handleSurchargeBlur} + /> +
+ + ); + })} +
+ + )} +
+
Order costs @@ -614,14 +816,24 @@ export function ConfigPanel({ Rewards points
-
+
Points per $ - {Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'} + {Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}
-
- Redemption rate - {formatPercent(redemptionRate)} +
+ + { + onConfigInputChange(); + onRedemptionRateChange(parseNumber(event.target.value, 90) / 100); + }} + onBlur={handleFieldBlur} + />
diff --git a/inventory/src/pages/Dashboard.tsx b/inventory/src/pages/Dashboard.tsx index 56be0fe..7078e92 100644 --- a/inventory/src/pages/Dashboard.tsx +++ b/inventory/src/pages/Dashboard.tsx @@ -14,6 +14,8 @@ import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard"; import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics"; import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard"; import TypeformDashboard from "@/components/dashboard/TypeformDashboard"; +import PayrollMetrics from "@/components/dashboard/PayrollMetrics"; +import OperationsMetrics from "@/components/dashboard/OperationsMetrics"; import Header from "@/components/dashboard/Header"; import Navigation from "@/components/dashboard/Navigation"; @@ -55,6 +57,16 @@ export function Dashboard() {
+ +
+
+ +
+
+ +
+
+
diff --git a/inventory/src/pages/DiscountSimulator.tsx b/inventory/src/pages/DiscountSimulator.tsx index c7f2f7a..bd4008d 100644 --- a/inventory/src/pages/DiscountSimulator.tsx +++ b/inventory/src/pages/DiscountSimulator.tsx @@ -16,13 +16,15 @@ import { DiscountPromoType, ShippingPromoType, ShippingTierConfig, + SurchargeConfig, CogsCalculationMode, } from "@/types/discount-simulator"; import { useToast } from "@/hooks/use-toast"; const DEFAULT_POINT_VALUE = 0.005; +const DEFAULT_REDEMPTION_RATE = 0.9; const DEFAULT_MERCHANT_FEE = 2.9; -const DEFAULT_FIXED_COST = 1.5; +const DEFAULT_FIXED_COST = 1.25; const STORAGE_KEY = 'discount-simulator-config-v1'; const getDefaultDateRange = (): DateRange => ({ @@ -56,11 +58,13 @@ export function DiscountSimulator() { const [productPromo, setProductPromo] = useState(defaultProductPromo); const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo); const [shippingTiers, setShippingTiers] = useState([]); + const [surcharges, setSurcharges] = useState([]); const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE); const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST); const [cogsCalculationMode, setCogsCalculationMode] = useState('actual'); const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE); const [pointDollarTouched, setPointDollarTouched] = useState(false); + const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE); const [simulationResult, setSimulationResult] = useState(undefined); const [baselineResult, setBaselineResult] = useState(undefined); const [isSimulating, setIsSimulating] = useState(false); @@ -135,7 +139,7 @@ export function DiscountSimulator() { const payloadPointsConfig = { pointsPerDollar: null, - redemptionRate: null, + redemptionRate, pointDollarValue, }; @@ -156,6 +160,11 @@ export function DiscountSimulator() { void id; return rest; }), + surcharges: surcharges.map((surcharge) => { + const { id, ...rest } = surcharge; + void id; + return rest; + }), merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, @@ -168,10 +177,12 @@ export function DiscountSimulator() { productPromo, shippingPromo, shippingTiers, + surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue, + redemptionRate, ]); const simulationMutation = useMutation< @@ -249,6 +260,7 @@ export function DiscountSimulator() { productPromo?: typeof defaultProductPromo; shippingPromo?: typeof defaultShippingPromo; shippingTiers?: ShippingTierConfig[]; + surcharges?: SurchargeConfig[]; merchantFeePercent?: number; fixedCostPerOrder?: number; cogsCalculationMode?: CogsCalculationMode; @@ -258,6 +270,7 @@ export function DiscountSimulator() { pointDollarValue?: number | null; }; pointDollarValue?: number; + redemptionRate?: number; }; skipAutoRunRef.current = true; @@ -290,6 +303,10 @@ export function DiscountSimulator() { setShippingTiers(parsed.shippingTiers); } + if (Array.isArray(parsed.surcharges)) { + setSurcharges(parsed.surcharges); + } + if (typeof parsed.merchantFeePercent === 'number') { setMerchantFeePercent(parsed.merchantFeePercent); } @@ -312,6 +329,10 @@ export function DiscountSimulator() { setPointDollarTouched(true); } + if (typeof parsed.redemptionRate === 'number') { + setRedemptionRate(parsed.redemptionRate); + } + setLoadedFromStorage(true); } catch (error) { console.error('Failed to load discount simulator config', error); @@ -336,12 +357,14 @@ export function DiscountSimulator() { productPromo, shippingPromo, shippingTiers, + surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue, + redemptionRate, }); - }, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue]); + }, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue, redemptionRate]); useEffect(() => { if (!hasLoadedConfig) { @@ -388,7 +411,6 @@ export function DiscountSimulator() { }, [loadedFromStorage, runSimulation]); const currentPointsPerDollar = simulationResult?.totals?.pointsPerDollar ?? 0; - const currentRedemptionRate = simulationResult?.totals?.redemptionRate ?? 0; const recommendedPointDollarValue = simulationResult?.totals?.pointDollarValue; const handlePointDollarValueChange = (value: number) => { @@ -422,11 +444,13 @@ export function DiscountSimulator() { setProductPromo(defaultProductPromo); setShippingPromo(defaultShippingPromo); setShippingTiers([]); + setSurcharges([]); setMerchantFeePercent(DEFAULT_MERCHANT_FEE); setFixedCostPerOrder(DEFAULT_FIXED_COST); setCogsCalculationMode('actual'); setPointDollarValue(DEFAULT_POINT_VALUE); setPointDollarTouched(false); + setRedemptionRate(DEFAULT_REDEMPTION_RATE); setSimulationResult(undefined); if (typeof window !== 'undefined') { @@ -451,7 +475,7 @@ export function DiscountSimulator() {

Discount Simulator

-
+
{/* Left Sidebar - Configuration */}
setShippingPromo((prev) => ({ ...prev, ...update }))} shippingTiers={shippingTiers} onShippingTiersChange={setShippingTiers} + surcharges={surcharges} + onSurchargesChange={setSurcharges} merchantFeePercent={merchantFeePercent} onMerchantFeeChange={setMerchantFeePercent} fixedCostPerOrder={fixedCostPerOrder} @@ -474,7 +500,8 @@ export function DiscountSimulator() { cogsCalculationMode={cogsCalculationMode} onCogsCalculationModeChange={setCogsCalculationMode} pointsPerDollar={currentPointsPerDollar} - redemptionRate={currentRedemptionRate} + redemptionRate={redemptionRate} + onRedemptionRateChange={setRedemptionRate} pointDollarValue={pointDollarValue} onPointDollarValueChange={handlePointDollarValueChange} onConfigInputChange={handleConfigInputChange} diff --git a/inventory/src/services/dashboard/acotService.js b/inventory/src/services/dashboard/acotService.js index d0fa1a2..02612a8 100644 --- a/inventory/src/services/dashboard/acotService.js +++ b/inventory/src/services/dashboard/acotService.js @@ -214,6 +214,39 @@ export const acotService = { ); }, + // Get employee metrics data (hours, picking, shipping) - legacy, kept for backwards compatibility + getEmployeeMetrics: async (params) => { + const cacheKey = `employee_metrics_${JSON.stringify(params)}`; + return deduplicatedRequest(cacheKey, () => + retryRequest(async () => { + const response = await acotApi.get('/api/acot/employee-metrics', { params }); + return response.data; + }) + ); + }, + + // Get payroll metrics data (hours, overtime, pay periods) + getPayrollMetrics: async (params) => { + const cacheKey = `payroll_metrics_${JSON.stringify(params)}`; + return deduplicatedRequest(cacheKey, () => + retryRequest(async () => { + const response = await acotApi.get('/api/acot/payroll-metrics', { params }); + return response.data; + }) + ); + }, + + // Get operations metrics data (picking, shipping) + getOperationsMetrics: async (params) => { + const cacheKey = `operations_metrics_${JSON.stringify(params)}`; + return deduplicatedRequest(cacheKey, () => + retryRequest(async () => { + const response = await acotApi.get('/api/acot/operations-metrics', { params }); + return response.data; + }) + ); + }, + // Utility functions clearCache, }; diff --git a/inventory/src/types/discount-simulator.ts b/inventory/src/types/discount-simulator.ts index a70811a..33c9015 100644 --- a/inventory/src/types/discount-simulator.ts +++ b/inventory/src/types/discount-simulator.ts @@ -19,6 +19,16 @@ export interface ShippingTierConfig { id?: string; } +export type SurchargeTarget = 'shipping' | 'order'; + +export interface SurchargeConfig { + threshold: number; + maxThreshold?: number; + target: SurchargeTarget; + amount: number; + id?: string; +} + export interface DiscountSimulationBucket { key: string; label: string; @@ -33,6 +43,8 @@ export interface DiscountSimulationBucket { shippingChargeBase: number; shippingAfterAuto: number; shipPromoDiscount: number; + shippingSurcharge: number; + orderSurcharge: number; customerShipCost: number; actualShippingCost: number; totalRevenue: number; @@ -90,6 +102,7 @@ export interface DiscountSimulationRequest { maxDiscount: number; }; shippingTiers: ShippingTierConfig[]; + surcharges: SurchargeConfig[]; merchantFeePercent: number; fixedCostPerOrder: number; cogsCalculationMode: CogsCalculationMode;