Add surcharges to discount simulator, add new employee-related components to dashboard
This commit is contained in:
@@ -163,6 +163,7 @@ router.post('/simulate', async (req, res) => {
|
|||||||
productPromo = {},
|
productPromo = {},
|
||||||
shippingPromo = {},
|
shippingPromo = {},
|
||||||
shippingTiers = [],
|
shippingTiers = [],
|
||||||
|
surcharges = [],
|
||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
cogsCalculationMode = 'actual',
|
cogsCalculationMode = 'actual',
|
||||||
@@ -219,6 +220,17 @@ router.post('/simulate', async (req, res) => {
|
|||||||
.filter(tier => tier.threshold >= 0 && tier.value >= 0)
|
.filter(tier => tier.threshold >= 0 && tier.value >= 0)
|
||||||
.sort((a, b) => a.threshold - b.threshold)
|
.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: {
|
points: {
|
||||||
pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null,
|
pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null,
|
||||||
redemptionRate: typeof pointsConfig.redemptionRate === 'number' ? pointsConfig.redemptionRate : 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 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;
|
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
|
||||||
|
|
||||||
// Calculate COGS based on the selected mode
|
// Calculate COGS based on the selected mode
|
||||||
@@ -459,8 +471,23 @@ router.post('/simulate', async (req, res) => {
|
|||||||
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
|
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
|
||||||
}
|
}
|
||||||
|
|
||||||
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount);
|
// Calculate surcharges
|
||||||
const customerItemCost = Math.max(0, orderValue - promoProductDiscount);
|
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 totalRevenue = customerItemCost + customerShipCost;
|
||||||
|
|
||||||
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
|
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
|
||||||
@@ -488,6 +515,8 @@ router.post('/simulate', async (req, res) => {
|
|||||||
shippingChargeBase,
|
shippingChargeBase,
|
||||||
shippingAfterAuto,
|
shippingAfterAuto,
|
||||||
shipPromoDiscount,
|
shipPromoDiscount,
|
||||||
|
shippingSurcharge,
|
||||||
|
orderSurcharge,
|
||||||
customerShipCost,
|
customerShipCost,
|
||||||
actualShippingCost,
|
actualShippingCost,
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
495
inventory-server/dashboard/acot-server/routes/payroll-metrics.js
Normal file
495
inventory-server/dashboard/acot-server/routes/payroll-metrics.js
Normal file
@@ -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;
|
||||||
@@ -49,6 +49,9 @@ app.get('/health', (req, res) => {
|
|||||||
app.use('/api/acot/test', require('./routes/test'));
|
app.use('/api/acot/test', require('./routes/test'));
|
||||||
app.use('/api/acot/events', require('./routes/events'));
|
app.use('/api/acot/events', require('./routes/events'));
|
||||||
app.use('/api/acot/discounts', require('./routes/discounts'));
|
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
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|||||||
913
inventory/src/components/dashboard/OperationsMetrics.tsx
Normal file
913
inventory/src/components/dashboard/OperationsMetrics.tsx
Normal file
@@ -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<ChartSeriesKey, string> = {
|
||||||
|
ordersPicked: METRIC_COLORS.orders,
|
||||||
|
piecesPicked: METRIC_COLORS.aov,
|
||||||
|
ordersShipped: METRIC_COLORS.profit,
|
||||||
|
piecesShipped: METRIC_COLORS.secondary,
|
||||||
|
};
|
||||||
|
|
||||||
|
const SERIES_LABELS: Record<ChartSeriesKey, string> = {
|
||||||
|
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<CustomPeriod>({
|
||||||
|
type: "month",
|
||||||
|
startYear: currentYear,
|
||||||
|
startMonth: currentDate.getMonth(),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
const [isLast30DaysMode, setIsLast30DaysMode] = useState<boolean>(true);
|
||||||
|
const [isPeriodPopoverOpen, setIsPeriodPopoverOpen] = useState<boolean>(false);
|
||||||
|
const [metrics, setMetrics] = useState<Record<ChartSeriesKey, boolean>>({
|
||||||
|
ordersPicked: true,
|
||||||
|
piecesPicked: false,
|
||||||
|
ordersShipped: true,
|
||||||
|
piecesShipped: false,
|
||||||
|
});
|
||||||
|
const [groupBy, setGroupBy] = useState<GroupByOption>("day");
|
||||||
|
const [data, setData] = useState<OperationsMetricsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(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<string, string> = {};
|
||||||
|
|
||||||
|
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<ChartPoint[]>(() => {
|
||||||
|
if (!data?.trend?.length) return [];
|
||||||
|
|
||||||
|
const groupedData = new Map<string, {
|
||||||
|
label: string;
|
||||||
|
tooltipLabel: string;
|
||||||
|
timestamp: string;
|
||||||
|
ordersPicked: number;
|
||||||
|
piecesPicked: number;
|
||||||
|
ordersShipped: number;
|
||||||
|
piecesShipped: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
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 ? (
|
||||||
|
<>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="h-9" disabled={loading || !data?.byEmployee}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className={`p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
|
||||||
|
<DialogHeader className="flex-none">
|
||||||
|
<DialogTitle className="text-foreground">
|
||||||
|
Operations Details
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-auto mt-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">Picking by Employee</h3>
|
||||||
|
<div className={`rounded-lg border ${CARD_STYLES.base}`}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="whitespace-nowrap px-4">Employee</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Tickets</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Orders</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Pieces</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Hours</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Speed</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.byEmployee?.picking?.map((emp) => (
|
||||||
|
<TableRow key={emp.employeeId}>
|
||||||
|
<TableCell className="px-4">{emp.name}</TableCell>
|
||||||
|
<TableCell className="text-right px-4">{formatNumber(emp.ticketCount)}</TableCell>
|
||||||
|
<TableCell className="text-right px-4">{formatNumber(emp.ordersPicked)}</TableCell>
|
||||||
|
<TableCell className="text-right px-4">{formatNumber(emp.piecesPicked)}</TableCell>
|
||||||
|
<TableCell className="text-right px-4">{formatHours(emp.pickingHours || 0)}</TableCell>
|
||||||
|
<TableCell className="text-right px-4">
|
||||||
|
{emp.avgPickingSpeed != null ? `${formatNumber(emp.avgPickingSpeed, 1)}/h` : "—"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.byEmployee?.shipping && data.byEmployee.shipping.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-medium mb-2">Shipping by Employee</h3>
|
||||||
|
<div className={`rounded-lg border ${CARD_STYLES.base}`}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="whitespace-nowrap px-4">Employee</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Orders</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Pieces</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data.byEmployee.shipping.map((emp) => (
|
||||||
|
<TableRow key={emp.employeeId}>
|
||||||
|
<TableCell className="px-4">{emp.name}</TableCell>
|
||||||
|
<TableCell className="text-right px-4">{formatNumber(emp.ordersShipped)}</TableCell>
|
||||||
|
<TableCell className="text-right px-4">{formatNumber(emp.piecesShipped)}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<PeriodSelectionPopover
|
||||||
|
open={isPeriodPopoverOpen}
|
||||||
|
onOpenChange={setIsPeriodPopoverOpen}
|
||||||
|
selectedLabel={selectedRangeLabel}
|
||||||
|
referenceDate={currentDate}
|
||||||
|
isLast30DaysActive={isLast30DaysMode}
|
||||||
|
onQuickSelect={handleQuickPeriod}
|
||||||
|
onApplyResult={handleNaturalLanguageResult}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`w-full h-full ${CARD_STYLES.elevated}`}>
|
||||||
|
<DashboardSectionHeader
|
||||||
|
title="Operations"
|
||||||
|
size="large"
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardContent className="p-6 pt-0 space-y-4">
|
||||||
|
{!error && (
|
||||||
|
loading ? (
|
||||||
|
<SkeletonStats />
|
||||||
|
) : (
|
||||||
|
cards.length > 0 && <OperationsStatGrid cards={cards} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && (
|
||||||
|
<div className="flex items-center flex-col sm:flex-row gap-0 sm:gap-4">
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{SERIES_DEFINITIONS.map((series) => (
|
||||||
|
<Button
|
||||||
|
key={series.key}
|
||||||
|
variant={metrics[series.key] ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => toggleMetric(series.key)}
|
||||||
|
>
|
||||||
|
{series.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator orientation="vertical" className="h-6 hidden sm:block" />
|
||||||
|
<Separator orientation="horizontal" className="sm:hidden w-20 my-2" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="text-sm text-muted-foreground">Group:</div>
|
||||||
|
<Select value={groupBy} onValueChange={handleGroupByChange}>
|
||||||
|
<SelectTrigger className="w-[100px]">
|
||||||
|
<SelectValue placeholder="Group By" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{GROUP_BY_CHOICES.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ChartSkeleton type="area" height="default" withCard={false} />
|
||||||
|
) : error ? (
|
||||||
|
<DashboardErrorState error={`Failed to load operations data: ${error}`} className="mx-0 my-0" />
|
||||||
|
) : !hasData ? (
|
||||||
|
<DashboardEmptyState
|
||||||
|
icon={Package}
|
||||||
|
title="No operations data available"
|
||||||
|
description="Try selecting a different time range"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`h-[280px] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
||||||
|
{!hasActiveMetrics ? (
|
||||||
|
<DashboardEmptyState
|
||||||
|
icon={TrendingUp}
|
||||||
|
title="No metrics selected"
|
||||||
|
description="Select at least one metric to visualize."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<ComposedChart data={chartData} margin={{ top: 5, right: 15, left: 15, bottom: 5 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="operationsOrdersPicked" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor={chartColors.ordersPicked} stopOpacity={0.8} />
|
||||||
|
<stop offset="95%" stopColor={chartColors.ordersPicked} stopOpacity={0.3} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
tick={{ fill: "currentColor" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(value: number) => formatNumber(value)}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
tick={{ fill: "currentColor" }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<OperationsTooltip />} />
|
||||||
|
<Legend formatter={(value: string) => SERIES_LABELS[value as ChartSeriesKey] ?? value} />
|
||||||
|
|
||||||
|
{metrics.ordersPicked && (
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ordersPicked"
|
||||||
|
name={SERIES_LABELS.ordersPicked}
|
||||||
|
stroke={chartColors.ordersPicked}
|
||||||
|
fill="url(#operationsOrdersPicked)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{metrics.ordersShipped && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="ordersShipped"
|
||||||
|
name={SERIES_LABELS.ordersShipped}
|
||||||
|
stroke={chartColors.ordersShipped}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{metrics.piecesPicked && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="piecesPicked"
|
||||||
|
name={SERIES_LABELS.piecesPicked}
|
||||||
|
stroke={chartColors.piecesPicked}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="5 3"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{metrics.piecesShipped && (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="piecesShipped"
|
||||||
|
name={SERIES_LABELS.piecesShipped}
|
||||||
|
stroke={chartColors.piecesShipped}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ComposedChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<DashboardStatCard
|
||||||
|
key={card.key}
|
||||||
|
title={card.title}
|
||||||
|
value={card.value}
|
||||||
|
subtitle={card.description}
|
||||||
|
trend={
|
||||||
|
card.trendValue != null && Number.isFinite(card.trendValue)
|
||||||
|
? {
|
||||||
|
value: card.trendValue,
|
||||||
|
moreIsBetter: !card.trendInverted,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={ICON_MAP[card.key as keyof typeof ICON_MAP]}
|
||||||
|
iconColor={card.iconColor}
|
||||||
|
tooltip={card.tooltip}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonStats() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const OperationsTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
|
||||||
|
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 (
|
||||||
|
<div className={TOOLTIP_STYLES.container}>
|
||||||
|
<p className={TOOLTIP_STYLES.header}>{resolvedLabel}</p>
|
||||||
|
<div className={TOOLTIP_STYLES.content}>
|
||||||
|
{orderedPayload.map((entry, index) => {
|
||||||
|
const key = (entry.dataKey ?? "") as ChartSeriesKey;
|
||||||
|
const rawValue = entry.value;
|
||||||
|
const formattedValue = rawValue != null ? formatNumber(rawValue as number) : "—";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={`${key}-${index}`} className={TOOLTIP_STYLES.row}>
|
||||||
|
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||||
|
<span
|
||||||
|
className={TOOLTIP_STYLES.dot}
|
||||||
|
style={{ backgroundColor: entry.stroke || entry.color || "#888" }}
|
||||||
|
/>
|
||||||
|
<span className={TOOLTIP_STYLES.name}>
|
||||||
|
{SERIES_LABELS[key] ?? entry.name ?? key}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={TOOLTIP_STYLES.value}>{formattedValue}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OperationsMetrics;
|
||||||
558
inventory/src/components/dashboard/PayrollMetrics.tsx
Normal file
558
inventory/src/components/dashboard/PayrollMetrics.tsx
Normal file
@@ -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<PayrollMetricsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [currentPayPeriodStart, setCurrentPayPeriodStart] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch data
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
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 ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" className="h-9" disabled={loading || !data?.byEmployee}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className={`p-4 max-w-[95vw] w-fit max-h-[85vh] overflow-hidden flex flex-col ${CARD_STYLES.base}`}>
|
||||||
|
<DialogHeader className="flex-none">
|
||||||
|
<DialogTitle className="text-foreground">
|
||||||
|
Employee Hours - {data?.payPeriod?.label}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex-1 overflow-auto mt-6">
|
||||||
|
<div className={`rounded-lg border ${CARD_STYLES.base}`}>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="whitespace-nowrap px-4">Employee</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Week 1</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Week 2</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Total</TableHead>
|
||||||
|
<TableHead className="text-right whitespace-nowrap px-4">Overtime</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{data?.byEmployee?.map((emp) => (
|
||||||
|
<TableRow key={emp.employeeId}>
|
||||||
|
<TableCell className="px-4">{emp.name}</TableCell>
|
||||||
|
<TableCell className="text-right px-4">
|
||||||
|
<span className={emp.week1Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}>
|
||||||
|
{formatHours(emp.week1Hours)}
|
||||||
|
{emp.week1Overtime > 0 && (
|
||||||
|
<span className="ml-1 text-xs">
|
||||||
|
(+{formatHours(emp.week1Overtime)} OT)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right px-4">
|
||||||
|
<span className={emp.week2Overtime > 0 ? "text-orange-600 dark:text-orange-400 font-medium" : ""}>
|
||||||
|
{formatHours(emp.week2Hours)}
|
||||||
|
{emp.week2Overtime > 0 && (
|
||||||
|
<span className="ml-1 text-xs">
|
||||||
|
(+{formatHours(emp.week2Overtime)} OT)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right px-4 font-medium">
|
||||||
|
{formatHours(emp.totalHours)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right px-4">
|
||||||
|
{emp.overtimeHours > 0 ? (
|
||||||
|
<span className="text-orange-600 dark:text-orange-400 font-medium">
|
||||||
|
{formatHours(emp.overtimeHours)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => navigatePeriod("prev")}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-9 px-3 min-w-[180px]"
|
||||||
|
onClick={goToCurrentPeriod}
|
||||||
|
disabled={loading || data?.payPeriod?.isCurrent}
|
||||||
|
>
|
||||||
|
<Calendar className="h-4 w-4 mr-2" />
|
||||||
|
{loading ? "Loading..." : data?.payPeriod?.label || "Loading..."}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-9 w-9"
|
||||||
|
onClick={() => navigatePeriod("next")}
|
||||||
|
disabled={loading || data?.payPeriod?.isCurrent}
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`w-full h-full ${CARD_STYLES.elevated}`}>
|
||||||
|
<DashboardSectionHeader
|
||||||
|
title="Payroll"
|
||||||
|
size="large"
|
||||||
|
actions={headerActions}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CardContent className="p-6 pt-0 space-y-4">
|
||||||
|
{!error && (
|
||||||
|
loading ? (
|
||||||
|
<SkeletonStats />
|
||||||
|
) : (
|
||||||
|
cards.length > 0 && <PayrollStatGrid cards={cards} />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<ChartSkeleton type="bar" height="default" withCard={false} />
|
||||||
|
) : error ? (
|
||||||
|
<DashboardErrorState error={`Failed to load payroll data: ${error}`} className="mx-0 my-0" />
|
||||||
|
) : !hasData ? (
|
||||||
|
<DashboardEmptyState
|
||||||
|
icon={Clock}
|
||||||
|
title="No payroll data available"
|
||||||
|
description="Try selecting a different pay period"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={`h-[280px] ${CARD_STYLES.base} rounded-lg p-0 relative`}>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData} margin={{ top: 20, right: 20, left: 20, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="label"
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
tick={{ fill: "currentColor" }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(value: number) => `${value}h`}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
tick={{ fill: "currentColor" }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<PayrollTooltip />} />
|
||||||
|
<Legend />
|
||||||
|
<Bar
|
||||||
|
dataKey="regular"
|
||||||
|
name="Regular Hours"
|
||||||
|
stackId="hours"
|
||||||
|
fill={chartColors.regular}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="overtime"
|
||||||
|
name="Overtime"
|
||||||
|
stackId="hours"
|
||||||
|
fill={chartColors.overtime}
|
||||||
|
>
|
||||||
|
{chartData.map((entry, index) => (
|
||||||
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={entry.overtime > 0 ? chartColors.overtime : chartColors.regular}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && data?.byWeek && data.byWeek.some(w => w.overtime > 0) && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-orange-600 dark:text-orange-400">
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
Overtime detected: {formatHours(data.totals.overtimeHours)} total
|
||||||
|
({data.byEmployee?.filter(e => e.overtimeHours > 0).length || 0} employees)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full dashboard-stagger">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<DashboardStatCard
|
||||||
|
key={card.key}
|
||||||
|
title={card.title}
|
||||||
|
value={card.value}
|
||||||
|
subtitle={card.description}
|
||||||
|
trend={
|
||||||
|
card.trendValue != null && Number.isFinite(card.trendValue)
|
||||||
|
? {
|
||||||
|
value: card.trendValue,
|
||||||
|
moreIsBetter: !card.trendInverted,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
icon={ICON_MAP[card.key as keyof typeof ICON_MAP]}
|
||||||
|
iconColor={card.iconColor}
|
||||||
|
tooltip={card.tooltip}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonStats() {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 py-4 w-full">
|
||||||
|
{Array.from({ length: 4 }).map((_, index) => (
|
||||||
|
<DashboardStatCardSkeleton key={index} hasIcon hasSubtitle />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PayrollTooltip = ({ active, payload, label }: TooltipProps<number, string>) => {
|
||||||
|
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 (
|
||||||
|
<div className={TOOLTIP_STYLES.container}>
|
||||||
|
<p className={TOOLTIP_STYLES.header}>{label}</p>
|
||||||
|
<div className={TOOLTIP_STYLES.content}>
|
||||||
|
<div className={TOOLTIP_STYLES.row}>
|
||||||
|
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||||
|
<span
|
||||||
|
className={TOOLTIP_STYLES.dot}
|
||||||
|
style={{ backgroundColor: chartColors.regular }}
|
||||||
|
/>
|
||||||
|
<span className={TOOLTIP_STYLES.name}>Regular Hours</span>
|
||||||
|
</div>
|
||||||
|
<span className={TOOLTIP_STYLES.value}>{formatHours(regular || 0)}</span>
|
||||||
|
</div>
|
||||||
|
{overtime != null && overtime > 0 && (
|
||||||
|
<div className={TOOLTIP_STYLES.row}>
|
||||||
|
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||||
|
<span
|
||||||
|
className={TOOLTIP_STYLES.dot}
|
||||||
|
style={{ backgroundColor: chartColors.overtime }}
|
||||||
|
/>
|
||||||
|
<span className={TOOLTIP_STYLES.name}>Overtime</span>
|
||||||
|
</div>
|
||||||
|
<span className={TOOLTIP_STYLES.value}>{formatHours(overtime)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={`${TOOLTIP_STYLES.row} border-t border-border/50 pt-1 mt-1`}>
|
||||||
|
<div className={TOOLTIP_STYLES.rowLabel}>
|
||||||
|
<span className={TOOLTIP_STYLES.name}>Total</span>
|
||||||
|
</div>
|
||||||
|
<span className={`${TOOLTIP_STYLES.value} font-semibold`}>{formatHours(total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PayrollMetrics;
|
||||||
@@ -8,7 +8,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
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 { formatNumber } from "@/utils/productUtils";
|
||||||
import { PlusIcon, X } from "lucide-react";
|
import { PlusIcon, X } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -35,6 +35,8 @@ interface ConfigPanelProps {
|
|||||||
onShippingPromoChange: (update: Partial<ConfigPanelProps["shippingPromo"]>) => void;
|
onShippingPromoChange: (update: Partial<ConfigPanelProps["shippingPromo"]>) => void;
|
||||||
shippingTiers: ShippingTierConfig[];
|
shippingTiers: ShippingTierConfig[];
|
||||||
onShippingTiersChange: (tiers: ShippingTierConfig[]) => void;
|
onShippingTiersChange: (tiers: ShippingTierConfig[]) => void;
|
||||||
|
surcharges: SurchargeConfig[];
|
||||||
|
onSurchargesChange: (surcharges: SurchargeConfig[]) => void;
|
||||||
merchantFeePercent: number;
|
merchantFeePercent: number;
|
||||||
onMerchantFeeChange: (value: number) => void;
|
onMerchantFeeChange: (value: number) => void;
|
||||||
fixedCostPerOrder: number;
|
fixedCostPerOrder: number;
|
||||||
@@ -43,6 +45,7 @@ interface ConfigPanelProps {
|
|||||||
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
|
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
|
||||||
pointsPerDollar: number;
|
pointsPerDollar: number;
|
||||||
redemptionRate: number;
|
redemptionRate: number;
|
||||||
|
onRedemptionRateChange: (value: number) => void;
|
||||||
pointDollarValue: number;
|
pointDollarValue: number;
|
||||||
onPointDollarValueChange: (value: number) => void;
|
onPointDollarValueChange: (value: number) => void;
|
||||||
onConfigInputChange: () => void;
|
onConfigInputChange: () => void;
|
||||||
@@ -65,6 +68,7 @@ const formatPercent = (value: number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const generateTierId = () => `tier-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
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 => {
|
const parseDateToTimestamp = (value?: string | null): number | undefined => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -101,6 +105,8 @@ export function ConfigPanel({
|
|||||||
onShippingPromoChange,
|
onShippingPromoChange,
|
||||||
shippingTiers,
|
shippingTiers,
|
||||||
onShippingTiersChange,
|
onShippingTiersChange,
|
||||||
|
surcharges,
|
||||||
|
onSurchargesChange,
|
||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
onMerchantFeeChange,
|
onMerchantFeeChange,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
@@ -109,6 +115,7 @@ export function ConfigPanel({
|
|||||||
onCogsCalculationModeChange,
|
onCogsCalculationModeChange,
|
||||||
pointsPerDollar,
|
pointsPerDollar,
|
||||||
redemptionRate,
|
redemptionRate,
|
||||||
|
onRedemptionRateChange,
|
||||||
pointDollarValue,
|
pointDollarValue,
|
||||||
onPointDollarValueChange,
|
onPointDollarValueChange,
|
||||||
onConfigInputChange,
|
onConfigInputChange,
|
||||||
@@ -235,6 +242,93 @@ export function ConfigPanel({
|
|||||||
handleFieldBlur();
|
handleFieldBlur();
|
||||||
}, [sortShippingTiers, 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<SurchargeConfig>) => {
|
||||||
|
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 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";
|
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 fieldClass = "flex flex-col gap-1";
|
||||||
const labelClass = "text-[0.65rem] uppercase tracking-wide text-muted-foreground";
|
const labelClass = "text-[0.65rem] uppercase tracking-wide text-muted-foreground";
|
||||||
const fieldRowClass = "flex flex-col gap-2";
|
const fieldRowClass = "flex flex-col gap-2";
|
||||||
const fieldRowHorizontalClass = "flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-3";
|
const fieldRowHorizontalClass = "flex flex-col gap-2 sm:flex-row sm:gap-3";
|
||||||
const compactTriggerClass = "h-8 px-2 text-xs";
|
const compactTriggerClass = "h-8 px-1.5 text-xs";
|
||||||
const compactNumberClass = "h-8 px-2 text-sm";
|
const compactNumberClass = "h-8 px-1.5 text-sm";
|
||||||
const compactWideNumberClass = "h-8 px-2 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 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 showProductAdjustments = productPromo.type !== "none";
|
||||||
const showShippingAdjustments = shippingPromo.type !== "none";
|
const showShippingAdjustments = shippingPromo.type !== "none";
|
||||||
@@ -255,8 +349,8 @@ export function ConfigPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardContent className="flex flex-col gap-3 px-4 py-4">
|
<CardContent className="flex flex-col gap-2 px-2 py-2">
|
||||||
<div className="space-y-4">
|
<div className="space-y-2">
|
||||||
<section className={sectionClass}>
|
<section className={sectionClass}>
|
||||||
<div className={fieldRowClass}>
|
<div className={fieldRowClass}>
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
@@ -487,7 +581,7 @@ export function ConfigPanel({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={tierKey}
|
key={tierKey}
|
||||||
className="relative grid gap-2 rounded px-2 py-2 text-xs sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)_auto] sm:items-end"
|
className="relative grid gap-2 rounded px-2 py-0.5 text-xs sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)_auto] sm:items-end"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<Input
|
<Input
|
||||||
@@ -553,6 +647,114 @@ export function ConfigPanel({
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className={compactSectionClass}>
|
||||||
|
<div className={sectionHeaderClass}>
|
||||||
|
<span className={sectionTitleClass}>Surcharges</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleSurchargeAdd} className="flex items-center gap-1">
|
||||||
|
<PlusIcon className="w-3 h-3" />
|
||||||
|
Add surcharge
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{surcharges.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Add surcharges to model fees at different order values.</p>
|
||||||
|
) : (
|
||||||
|
<ScrollArea>
|
||||||
|
<div className="flex flex-col gap-2 pr-1 -mx-2">
|
||||||
|
<div className="grid gap-2 px-2 py-1 text-[0.65rem] font-medium uppercase tracking-[0.18em] text-muted-foreground sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1.2fr)_minmax(0,0.8fr)_auto]">
|
||||||
|
<div>Min</div>
|
||||||
|
<div>Max</div>
|
||||||
|
<div>Add To</div>
|
||||||
|
<div>Amount</div>
|
||||||
|
<div className="w-1.5" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
{surcharges.map((surcharge, index) => {
|
||||||
|
const surchargeKey = surcharge.id ?? `surcharge-${index}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={surchargeKey}
|
||||||
|
className="relative grid gap-2 rounded px-2 py-2 text-xs sm:grid-cols-[minmax(0,0.9fr)_minmax(0,0.9fr)_minmax(0,1fr)_minmax(0,0.9fr)_auto] sm:items-end"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={surcharge.threshold}
|
||||||
|
onChange={(event) => {
|
||||||
|
onConfigInputChange();
|
||||||
|
handleSurchargeUpdate(index, {
|
||||||
|
threshold: parseNumber(event.target.value, 0),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onBlur={handleSurchargeBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
placeholder="∞"
|
||||||
|
value={surcharge.maxThreshold ?? ''}
|
||||||
|
onChange={(event) => {
|
||||||
|
onConfigInputChange();
|
||||||
|
const val = event.target.value;
|
||||||
|
handleSurchargeUpdate(index, {
|
||||||
|
maxThreshold: val === '' ? undefined : parseNumber(val, 0),
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onBlur={handleSurchargeBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
value={surcharge.target}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onConfigInputChange();
|
||||||
|
handleSurchargeUpdate(index, { target: value as SurchargeConfig["target"] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="shipping">Shipping</SelectItem>
|
||||||
|
<SelectItem value="order">Order</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={surcharge.amount}
|
||||||
|
onChange={(event) => {
|
||||||
|
onConfigInputChange();
|
||||||
|
handleSurchargeUpdate(index, { amount: parseNumber(event.target.value, 0) });
|
||||||
|
}}
|
||||||
|
onBlur={handleSurchargeBlur}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="w-1.5" aria-hidden="true" />
|
||||||
|
<div className="absolute -right-0.5 top-1/2 -translate-y-1/2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSurchargeRemove(index)}
|
||||||
|
className="p-1"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className={sectionClass}>
|
<section className={sectionClass}>
|
||||||
<div className={sectionHeaderClass}>
|
<div className={sectionHeaderClass}>
|
||||||
<span className={sectionTitleClass}>Order costs</span>
|
<span className={sectionTitleClass}>Order costs</span>
|
||||||
@@ -614,14 +816,24 @@ export function ConfigPanel({
|
|||||||
<span className={sectionTitleClass}>Rewards points</span>
|
<span className={sectionTitleClass}>Rewards points</span>
|
||||||
</div>
|
</div>
|
||||||
<div className={fieldRowClass}>
|
<div className={fieldRowClass}>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className={fieldRowHorizontalClass}>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className={labelClass}>Points per $</span>
|
<span className={labelClass}>Points per $</span>
|
||||||
<span className="text-sm font-medium">{Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}</span>
|
<span className="text-sm font-medium mt-1">{Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className={fieldClass}>
|
||||||
<span className={labelClass}>Redemption rate</span>
|
<Label className={labelClass}>Redemption rate (%)</Label>
|
||||||
<span className="text-sm font-medium">{formatPercent(redemptionRate)}</span>
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={Math.round(redemptionRate * 100)}
|
||||||
|
onChange={(event) => {
|
||||||
|
onConfigInputChange();
|
||||||
|
onRedemptionRateChange(parseNumber(event.target.value, 90) / 100);
|
||||||
|
}}
|
||||||
|
onBlur={handleFieldBlur}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import AnalyticsDashboard from "@/components/dashboard/AnalyticsDashboard";
|
|||||||
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
import RealtimeAnalytics from "@/components/dashboard/RealtimeAnalytics";
|
||||||
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
import UserBehaviorDashboard from "@/components/dashboard/UserBehaviorDashboard";
|
||||||
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
import TypeformDashboard from "@/components/dashboard/TypeformDashboard";
|
||||||
|
import PayrollMetrics from "@/components/dashboard/PayrollMetrics";
|
||||||
|
import OperationsMetrics from "@/components/dashboard/OperationsMetrics";
|
||||||
import Header from "@/components/dashboard/Header";
|
import Header from "@/components/dashboard/Header";
|
||||||
import Navigation from "@/components/dashboard/Navigation";
|
import Navigation from "@/components/dashboard/Navigation";
|
||||||
|
|
||||||
@@ -55,6 +57,16 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Protected>
|
</Protected>
|
||||||
|
<Protected permission="dashboard:employee_metrics">
|
||||||
|
<div className="grid grid-cols-12 gap-4">
|
||||||
|
<div id="payroll-metrics" className="col-span-12 lg:col-span-6">
|
||||||
|
<PayrollMetrics />
|
||||||
|
</div>
|
||||||
|
<div id="operations-metrics" className="col-span-12 lg:col-span-6">
|
||||||
|
<OperationsMetrics />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Protected>
|
||||||
<div className="grid grid-cols-12 gap-4">
|
<div className="grid grid-cols-12 gap-4">
|
||||||
<Protected permission="dashboard:feed">
|
<Protected permission="dashboard:feed">
|
||||||
<div id="feed" className="col-span-12 lg:col-span-6 xl:col-span-4 h-[600px] lg:h-[740px]">
|
<div id="feed" className="col-span-12 lg:col-span-6 xl:col-span-4 h-[600px] lg:h-[740px]">
|
||||||
|
|||||||
@@ -16,13 +16,15 @@ import {
|
|||||||
DiscountPromoType,
|
DiscountPromoType,
|
||||||
ShippingPromoType,
|
ShippingPromoType,
|
||||||
ShippingTierConfig,
|
ShippingTierConfig,
|
||||||
|
SurchargeConfig,
|
||||||
CogsCalculationMode,
|
CogsCalculationMode,
|
||||||
} from "@/types/discount-simulator";
|
} from "@/types/discount-simulator";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
const DEFAULT_POINT_VALUE = 0.005;
|
const DEFAULT_POINT_VALUE = 0.005;
|
||||||
|
const DEFAULT_REDEMPTION_RATE = 0.9;
|
||||||
const DEFAULT_MERCHANT_FEE = 2.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 STORAGE_KEY = 'discount-simulator-config-v1';
|
||||||
|
|
||||||
const getDefaultDateRange = (): DateRange => ({
|
const getDefaultDateRange = (): DateRange => ({
|
||||||
@@ -56,11 +58,13 @@ export function DiscountSimulator() {
|
|||||||
const [productPromo, setProductPromo] = useState(defaultProductPromo);
|
const [productPromo, setProductPromo] = useState(defaultProductPromo);
|
||||||
const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo);
|
const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo);
|
||||||
const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]);
|
const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]);
|
||||||
|
const [surcharges, setSurcharges] = useState<SurchargeConfig[]>([]);
|
||||||
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
|
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
|
||||||
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
|
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
|
||||||
const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
|
const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
|
||||||
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
|
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
|
||||||
const [pointDollarTouched, setPointDollarTouched] = useState(false);
|
const [pointDollarTouched, setPointDollarTouched] = useState(false);
|
||||||
|
const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE);
|
||||||
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
||||||
const [baselineResult, setBaselineResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
const [baselineResult, setBaselineResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
||||||
const [isSimulating, setIsSimulating] = useState(false);
|
const [isSimulating, setIsSimulating] = useState(false);
|
||||||
@@ -135,7 +139,7 @@ export function DiscountSimulator() {
|
|||||||
|
|
||||||
const payloadPointsConfig = {
|
const payloadPointsConfig = {
|
||||||
pointsPerDollar: null,
|
pointsPerDollar: null,
|
||||||
redemptionRate: null,
|
redemptionRate,
|
||||||
pointDollarValue,
|
pointDollarValue,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -156,6 +160,11 @@ export function DiscountSimulator() {
|
|||||||
void id;
|
void id;
|
||||||
return rest;
|
return rest;
|
||||||
}),
|
}),
|
||||||
|
surcharges: surcharges.map((surcharge) => {
|
||||||
|
const { id, ...rest } = surcharge;
|
||||||
|
void id;
|
||||||
|
return rest;
|
||||||
|
}),
|
||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
cogsCalculationMode,
|
cogsCalculationMode,
|
||||||
@@ -168,10 +177,12 @@ export function DiscountSimulator() {
|
|||||||
productPromo,
|
productPromo,
|
||||||
shippingPromo,
|
shippingPromo,
|
||||||
shippingTiers,
|
shippingTiers,
|
||||||
|
surcharges,
|
||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
cogsCalculationMode,
|
cogsCalculationMode,
|
||||||
pointDollarValue,
|
pointDollarValue,
|
||||||
|
redemptionRate,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const simulationMutation = useMutation<
|
const simulationMutation = useMutation<
|
||||||
@@ -249,6 +260,7 @@ export function DiscountSimulator() {
|
|||||||
productPromo?: typeof defaultProductPromo;
|
productPromo?: typeof defaultProductPromo;
|
||||||
shippingPromo?: typeof defaultShippingPromo;
|
shippingPromo?: typeof defaultShippingPromo;
|
||||||
shippingTiers?: ShippingTierConfig[];
|
shippingTiers?: ShippingTierConfig[];
|
||||||
|
surcharges?: SurchargeConfig[];
|
||||||
merchantFeePercent?: number;
|
merchantFeePercent?: number;
|
||||||
fixedCostPerOrder?: number;
|
fixedCostPerOrder?: number;
|
||||||
cogsCalculationMode?: CogsCalculationMode;
|
cogsCalculationMode?: CogsCalculationMode;
|
||||||
@@ -258,6 +270,7 @@ export function DiscountSimulator() {
|
|||||||
pointDollarValue?: number | null;
|
pointDollarValue?: number | null;
|
||||||
};
|
};
|
||||||
pointDollarValue?: number;
|
pointDollarValue?: number;
|
||||||
|
redemptionRate?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
skipAutoRunRef.current = true;
|
skipAutoRunRef.current = true;
|
||||||
@@ -290,6 +303,10 @@ export function DiscountSimulator() {
|
|||||||
setShippingTiers(parsed.shippingTiers);
|
setShippingTiers(parsed.shippingTiers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(parsed.surcharges)) {
|
||||||
|
setSurcharges(parsed.surcharges);
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof parsed.merchantFeePercent === 'number') {
|
if (typeof parsed.merchantFeePercent === 'number') {
|
||||||
setMerchantFeePercent(parsed.merchantFeePercent);
|
setMerchantFeePercent(parsed.merchantFeePercent);
|
||||||
}
|
}
|
||||||
@@ -312,6 +329,10 @@ export function DiscountSimulator() {
|
|||||||
setPointDollarTouched(true);
|
setPointDollarTouched(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof parsed.redemptionRate === 'number') {
|
||||||
|
setRedemptionRate(parsed.redemptionRate);
|
||||||
|
}
|
||||||
|
|
||||||
setLoadedFromStorage(true);
|
setLoadedFromStorage(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load discount simulator config', error);
|
console.error('Failed to load discount simulator config', error);
|
||||||
@@ -336,12 +357,14 @@ export function DiscountSimulator() {
|
|||||||
productPromo,
|
productPromo,
|
||||||
shippingPromo,
|
shippingPromo,
|
||||||
shippingTiers,
|
shippingTiers,
|
||||||
|
surcharges,
|
||||||
merchantFeePercent,
|
merchantFeePercent,
|
||||||
fixedCostPerOrder,
|
fixedCostPerOrder,
|
||||||
cogsCalculationMode,
|
cogsCalculationMode,
|
||||||
pointDollarValue,
|
pointDollarValue,
|
||||||
|
redemptionRate,
|
||||||
});
|
});
|
||||||
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue]);
|
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue, redemptionRate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasLoadedConfig) {
|
if (!hasLoadedConfig) {
|
||||||
@@ -388,7 +411,6 @@ export function DiscountSimulator() {
|
|||||||
}, [loadedFromStorage, runSimulation]);
|
}, [loadedFromStorage, runSimulation]);
|
||||||
|
|
||||||
const currentPointsPerDollar = simulationResult?.totals?.pointsPerDollar ?? 0;
|
const currentPointsPerDollar = simulationResult?.totals?.pointsPerDollar ?? 0;
|
||||||
const currentRedemptionRate = simulationResult?.totals?.redemptionRate ?? 0;
|
|
||||||
const recommendedPointDollarValue = simulationResult?.totals?.pointDollarValue;
|
const recommendedPointDollarValue = simulationResult?.totals?.pointDollarValue;
|
||||||
|
|
||||||
const handlePointDollarValueChange = (value: number) => {
|
const handlePointDollarValueChange = (value: number) => {
|
||||||
@@ -422,11 +444,13 @@ export function DiscountSimulator() {
|
|||||||
setProductPromo(defaultProductPromo);
|
setProductPromo(defaultProductPromo);
|
||||||
setShippingPromo(defaultShippingPromo);
|
setShippingPromo(defaultShippingPromo);
|
||||||
setShippingTiers([]);
|
setShippingTiers([]);
|
||||||
|
setSurcharges([]);
|
||||||
setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
|
setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
|
||||||
setFixedCostPerOrder(DEFAULT_FIXED_COST);
|
setFixedCostPerOrder(DEFAULT_FIXED_COST);
|
||||||
setCogsCalculationMode('actual');
|
setCogsCalculationMode('actual');
|
||||||
setPointDollarValue(DEFAULT_POINT_VALUE);
|
setPointDollarValue(DEFAULT_POINT_VALUE);
|
||||||
setPointDollarTouched(false);
|
setPointDollarTouched(false);
|
||||||
|
setRedemptionRate(DEFAULT_REDEMPTION_RATE);
|
||||||
setSimulationResult(undefined);
|
setSimulationResult(undefined);
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -451,7 +475,7 @@ export function DiscountSimulator() {
|
|||||||
<h1 className="text-3xl font-bold">Discount Simulator</h1>
|
<h1 className="text-3xl font-bold">Discount Simulator</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-[300px,1fr] xl:grid-cols-[300px,1fr]">
|
<div className="grid gap-4 md:grid-cols-[300px,1fr] lg:grid-cols-[340px,1fr]">
|
||||||
{/* Left Sidebar - Configuration */}
|
{/* Left Sidebar - Configuration */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ConfigPanel
|
<ConfigPanel
|
||||||
@@ -467,6 +491,8 @@ export function DiscountSimulator() {
|
|||||||
onShippingPromoChange={(update) => setShippingPromo((prev) => ({ ...prev, ...update }))}
|
onShippingPromoChange={(update) => setShippingPromo((prev) => ({ ...prev, ...update }))}
|
||||||
shippingTiers={shippingTiers}
|
shippingTiers={shippingTiers}
|
||||||
onShippingTiersChange={setShippingTiers}
|
onShippingTiersChange={setShippingTiers}
|
||||||
|
surcharges={surcharges}
|
||||||
|
onSurchargesChange={setSurcharges}
|
||||||
merchantFeePercent={merchantFeePercent}
|
merchantFeePercent={merchantFeePercent}
|
||||||
onMerchantFeeChange={setMerchantFeePercent}
|
onMerchantFeeChange={setMerchantFeePercent}
|
||||||
fixedCostPerOrder={fixedCostPerOrder}
|
fixedCostPerOrder={fixedCostPerOrder}
|
||||||
@@ -474,7 +500,8 @@ export function DiscountSimulator() {
|
|||||||
cogsCalculationMode={cogsCalculationMode}
|
cogsCalculationMode={cogsCalculationMode}
|
||||||
onCogsCalculationModeChange={setCogsCalculationMode}
|
onCogsCalculationModeChange={setCogsCalculationMode}
|
||||||
pointsPerDollar={currentPointsPerDollar}
|
pointsPerDollar={currentPointsPerDollar}
|
||||||
redemptionRate={currentRedemptionRate}
|
redemptionRate={redemptionRate}
|
||||||
|
onRedemptionRateChange={setRedemptionRate}
|
||||||
pointDollarValue={pointDollarValue}
|
pointDollarValue={pointDollarValue}
|
||||||
onPointDollarValueChange={handlePointDollarValueChange}
|
onPointDollarValueChange={handlePointDollarValueChange}
|
||||||
onConfigInputChange={handleConfigInputChange}
|
onConfigInputChange={handleConfigInputChange}
|
||||||
|
|||||||
@@ -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
|
// Utility functions
|
||||||
clearCache,
|
clearCache,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export interface ShippingTierConfig {
|
|||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SurchargeTarget = 'shipping' | 'order';
|
||||||
|
|
||||||
|
export interface SurchargeConfig {
|
||||||
|
threshold: number;
|
||||||
|
maxThreshold?: number;
|
||||||
|
target: SurchargeTarget;
|
||||||
|
amount: number;
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DiscountSimulationBucket {
|
export interface DiscountSimulationBucket {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -33,6 +43,8 @@ export interface DiscountSimulationBucket {
|
|||||||
shippingChargeBase: number;
|
shippingChargeBase: number;
|
||||||
shippingAfterAuto: number;
|
shippingAfterAuto: number;
|
||||||
shipPromoDiscount: number;
|
shipPromoDiscount: number;
|
||||||
|
shippingSurcharge: number;
|
||||||
|
orderSurcharge: number;
|
||||||
customerShipCost: number;
|
customerShipCost: number;
|
||||||
actualShippingCost: number;
|
actualShippingCost: number;
|
||||||
totalRevenue: number;
|
totalRevenue: number;
|
||||||
@@ -90,6 +102,7 @@ export interface DiscountSimulationRequest {
|
|||||||
maxDiscount: number;
|
maxDiscount: number;
|
||||||
};
|
};
|
||||||
shippingTiers: ShippingTierConfig[];
|
shippingTiers: ShippingTierConfig[];
|
||||||
|
surcharges: SurchargeConfig[];
|
||||||
merchantFeePercent: number;
|
merchantFeePercent: number;
|
||||||
fixedCostPerOrder: number;
|
fixedCostPerOrder: number;
|
||||||
cogsCalculationMode: CogsCalculationMode;
|
cogsCalculationMode: CogsCalculationMode;
|
||||||
|
|||||||
Reference in New Issue
Block a user