Auth fixes, show correct cost each value on pos
This commit is contained in:
@@ -51,7 +51,8 @@ router.get('/stats', async (req, res) => {
|
|||||||
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
|
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
|
||||||
const { connection, release } = await getDbConnection();
|
const { connection, release } = await getDbConnection();
|
||||||
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||||
|
try {
|
||||||
|
|
||||||
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
// Main order stats query (optionally excludes Cherry Box orders)
|
// Main order stats query (optionally excludes Cherry Box orders)
|
||||||
@@ -374,33 +375,27 @@ router.get('/stats', async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return { response, release };
|
return response;
|
||||||
};
|
} finally {
|
||||||
|
// Always release the connection regardless of whether the outer Promise.race
|
||||||
// Race between the main operation and timeout
|
// used our result. If the timeout wins, this IIFE keeps running in the
|
||||||
let result;
|
// background until MySQL responds, then this finally releases. Without it,
|
||||||
try {
|
// every timed-out request permanently leaks one pool slot.
|
||||||
result = await Promise.race([mainOperation(), timeoutPromise]);
|
release();
|
||||||
} catch (error) {
|
|
||||||
// If it's a timeout, we don't have a release function to call
|
|
||||||
if (error.message.includes('timeout')) {
|
|
||||||
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
// For other errors, re-throw
|
};
|
||||||
throw error;
|
|
||||||
}
|
const response = await Promise.race([mainOperation(), timeoutPromise]);
|
||||||
|
|
||||||
const { response, release } = result;
|
|
||||||
|
|
||||||
// Release connection back to pool
|
|
||||||
if (release) release();
|
|
||||||
|
|
||||||
console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`);
|
console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`);
|
||||||
res.json(response);
|
res.json(response);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in /stats:', error);
|
if (error.message.includes('timeout')) {
|
||||||
|
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
|
||||||
|
} else {
|
||||||
|
console.error('Error in /stats:', error);
|
||||||
|
}
|
||||||
console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`);
|
console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ router.get('/', async (req, res) => {
|
|||||||
console.log(`[OPERATIONS-METRICS] Getting DB connection...`);
|
console.log(`[OPERATIONS-METRICS] Getting DB connection...`);
|
||||||
const { connection, release } = await getDbConnection();
|
const { connection, release } = await getDbConnection();
|
||||||
console.log(`[OPERATIONS-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
console.log(`[OPERATIONS-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||||
|
try {
|
||||||
|
|
||||||
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
|
||||||
|
|
||||||
@@ -370,29 +371,26 @@ router.get('/', async (req, res) => {
|
|||||||
trend,
|
trend,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { response, release };
|
return response;
|
||||||
|
} finally {
|
||||||
|
// Always release the connection regardless of who wins Promise.race.
|
||||||
|
// If the timeout wins, this IIFE keeps running until MySQL responds; this
|
||||||
|
// finally ensures the connection still returns to the pool.
|
||||||
|
release();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let result;
|
const response = await Promise.race([mainOperation(), timeoutPromise]);
|
||||||
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`);
|
console.log(`[OPERATIONS-METRICS] Request completed in ${Date.now() - startTime}ms`);
|
||||||
res.json(response);
|
res.json(response);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in /operations-metrics:', error);
|
if (error.message.includes('timeout')) {
|
||||||
|
console.log(`[OPERATIONS-METRICS] Request timed out in ${Date.now() - startTime}ms`);
|
||||||
|
} else {
|
||||||
|
console.error('Error in /operations-metrics:', error);
|
||||||
|
}
|
||||||
console.log(`[OPERATIONS-METRICS] Request failed in ${Date.now() - startTime}ms`);
|
console.log(`[OPERATIONS-METRICS] Request failed in ${Date.now() - startTime}ms`);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ router.get('/', async (req, res) => {
|
|||||||
console.log(`[PAYROLL-METRICS] Getting DB connection...`);
|
console.log(`[PAYROLL-METRICS] Getting DB connection...`);
|
||||||
const { connection, release } = await getDbConnection();
|
const { connection, release } = await getDbConnection();
|
||||||
console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
|
||||||
|
try {
|
||||||
|
|
||||||
// Build query for the pay period
|
// Build query for the pay period
|
||||||
const periodStart = payPeriod.start.toJSDate();
|
const periodStart = payPeriod.start.toJSDate();
|
||||||
@@ -373,29 +374,26 @@ router.get('/', async (req, res) => {
|
|||||||
byWeek: hoursData.byWeek,
|
byWeek: hoursData.byWeek,
|
||||||
};
|
};
|
||||||
|
|
||||||
return { response, release };
|
return response;
|
||||||
|
} finally {
|
||||||
|
// Always release the connection regardless of who wins Promise.race.
|
||||||
|
// If the timeout wins, this IIFE keeps running until MySQL responds; this
|
||||||
|
// finally ensures the connection still returns to the pool.
|
||||||
|
release();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let result;
|
const response = await Promise.race([mainOperation(), timeoutPromise]);
|
||||||
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`);
|
console.log(`[PAYROLL-METRICS] Request completed in ${Date.now() - startTime}ms`);
|
||||||
res.json(response);
|
res.json(response);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error in /payroll-metrics:', error);
|
if (error.message.includes('timeout')) {
|
||||||
|
console.log(`[PAYROLL-METRICS] Request timed out in ${Date.now() - startTime}ms`);
|
||||||
|
} else {
|
||||||
|
console.error('Error in /payroll-metrics:', error);
|
||||||
|
}
|
||||||
console.log(`[PAYROLL-METRICS] Request failed in ${Date.now() - startTime}ms`);
|
console.log(`[PAYROLL-METRICS] Request failed in ${Date.now() - startTime}ms`);
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ if (!process.env.JWT_SECRET) {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.ACOT_PORT) || 3012;
|
const PORT = Number(process.env.ACOT_PORT) || 3012;
|
||||||
|
|
||||||
|
// Trust X-Forwarded-* only when the immediate hop is loopback (Caddy on the same
|
||||||
|
// host). Required for the KIOSK_IPS bypass in shared/auth/middleware.js to see
|
||||||
|
// real client IPs instead of 127.0.0.1.
|
||||||
|
app.set('trust proxy', 'loopback');
|
||||||
|
|
||||||
// Postgres pool for authenticate() (user/permission lookups against inventory_db).
|
// Postgres pool for authenticate() (user/permission lookups against inventory_db).
|
||||||
// All MySQL access goes through db/connection.js (separate, ssh-tunneled).
|
// All MySQL access goes through db/connection.js (separate, ssh-tunneled).
|
||||||
const pool = new Pool({
|
const pool = new Pool({
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ if (!process.env.JWT_SECRET) {
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.DASHBOARD_PORT) || 3015;
|
const PORT = Number(process.env.DASHBOARD_PORT) || 3015;
|
||||||
|
|
||||||
|
// Trust X-Forwarded-* only when the immediate hop is loopback (Caddy on the same
|
||||||
|
// host). Required for the KIOSK_IPS bypass in shared/auth/middleware.js to see
|
||||||
|
// real client IPs instead of 127.0.0.1.
|
||||||
|
app.set('trust proxy', 'loopback');
|
||||||
|
|
||||||
// Single Postgres pool — used by authenticate() to load user permissions.
|
// Single Postgres pool — used by authenticate() to load user permissions.
|
||||||
// All four vendors share this pool (auth lookups are the only DB hits at runtime).
|
// All four vendors share this pool (auth lookups are the only DB hits at runtime).
|
||||||
const pool = createPool('DB');
|
const pool = createPool('DB');
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* One-off backfill: populate products.notions_cost_each and supplier_cost_each
|
||||||
|
* from MySQL supplier_item_data. Idempotent — safe to re-run.
|
||||||
|
*
|
||||||
|
* Usage (on the server, where the SSH tunnel and env are configured):
|
||||||
|
* cd /var/www/inventory && node scripts/backfill-supplier-costs.js
|
||||||
|
*
|
||||||
|
* After this lands, the daily products import (via syncSupplierCosts in
|
||||||
|
* scripts/import/products.js) keeps the columns up to date.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const dotenv = require("dotenv");
|
||||||
|
const path = require("path");
|
||||||
|
dotenv.config({ path: path.join(__dirname, "../.env") });
|
||||||
|
|
||||||
|
const { setupConnections, closeConnections } = require("./import/utils");
|
||||||
|
const { syncSupplierCosts } = require("./import/products");
|
||||||
|
|
||||||
|
const sshConfig = {
|
||||||
|
ssh: {
|
||||||
|
host: process.env.PROD_SSH_HOST,
|
||||||
|
port: process.env.PROD_SSH_PORT || 22,
|
||||||
|
username: process.env.PROD_SSH_USER,
|
||||||
|
privateKey: process.env.PROD_SSH_KEY_PATH
|
||||||
|
? require("fs").readFileSync(process.env.PROD_SSH_KEY_PATH)
|
||||||
|
: undefined,
|
||||||
|
compress: true,
|
||||||
|
},
|
||||||
|
prodDbConfig: {
|
||||||
|
host: process.env.PROD_DB_HOST || "localhost",
|
||||||
|
user: process.env.PROD_DB_USER,
|
||||||
|
password: process.env.PROD_DB_PASSWORD,
|
||||||
|
database: process.env.PROD_DB_NAME,
|
||||||
|
port: process.env.PROD_DB_PORT || 3306,
|
||||||
|
timezone: "-05:00",
|
||||||
|
},
|
||||||
|
localDbConfig: {
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
ssl: process.env.DB_SSL === "true",
|
||||||
|
connectionTimeoutMillis: 60000,
|
||||||
|
idleTimeoutMillis: 30000,
|
||||||
|
max: 4,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let connections;
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
console.log("Setting up connections...");
|
||||||
|
connections = await setupConnections(sshConfig);
|
||||||
|
const { prodConnection, localConnection } = connections;
|
||||||
|
|
||||||
|
console.log("Starting transaction...");
|
||||||
|
await localConnection.beginTransaction();
|
||||||
|
|
||||||
|
const result = await syncSupplierCosts(prodConnection, localConnection);
|
||||||
|
|
||||||
|
await localConnection.commit();
|
||||||
|
console.log(`Done. Updated ${result.updated} rows in ${(Date.now() - start) / 1000}s`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Backfill failed:", err);
|
||||||
|
if (connections?.localConnection?._transactionActive) {
|
||||||
|
try { await connections.localConnection.rollback(); } catch (e) {}
|
||||||
|
}
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
if (connections) {
|
||||||
|
try { await closeConnections(connections); } catch (e) { console.error("Close error:", e); }
|
||||||
|
}
|
||||||
|
process.exit();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -922,6 +922,11 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
// Cleanup temporary tables
|
// Cleanup temporary tables
|
||||||
await cleanupTemporaryTables(localConnection);
|
await cleanupTemporaryTables(localConnection);
|
||||||
|
|
||||||
|
// Sync supplier-quoted cost fields (notions_cost_each / supplier_cost_each).
|
||||||
|
// These feed the Create-PO page so the displayed cost matches what the
|
||||||
|
// legacy PHP backend will stamp onto the PO line item.
|
||||||
|
await syncSupplierCosts(prodConnection, localConnection);
|
||||||
|
|
||||||
// Commit the transaction
|
// Commit the transaction
|
||||||
await localConnection.commit();
|
await localConnection.commit();
|
||||||
|
|
||||||
@@ -954,10 +959,80 @@ async function importProducts(prodConnection, localConnection, incrementalUpdate
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bulk-sync supplier_item_data.notions_cost_each / supplier_cost_each into
|
||||||
|
// products.{notions_cost_each, supplier_cost_each}. These mirror the supplier-
|
||||||
|
// quoted "cost each" values the legacy PHP backend writes onto a PO when
|
||||||
|
// _product_add() runs (see po.class.php:189-209). Kept as a separate, idempotent
|
||||||
|
// pass so the main 49-column import paths don't need to know about it.
|
||||||
|
async function syncSupplierCosts(prodConnection, localConnection) {
|
||||||
|
outputProgress({
|
||||||
|
status: "running",
|
||||||
|
operation: "Products import",
|
||||||
|
message: "Syncing supplier costs from supplier_item_data"
|
||||||
|
});
|
||||||
|
|
||||||
|
const [rows] = await prodConnection.query(`
|
||||||
|
SELECT pid, notions_cost_each, supplier_cost_each
|
||||||
|
FROM supplier_item_data
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
|
return { updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stage into a temp table, then UPDATE in a single SQL statement.
|
||||||
|
await localConnection.query(`
|
||||||
|
CREATE TEMP TABLE temp_supplier_costs (
|
||||||
|
pid BIGINT PRIMARY KEY,
|
||||||
|
notions_cost_each NUMERIC(10,3),
|
||||||
|
supplier_cost_each NUMERIC(10,3)
|
||||||
|
) ON COMMIT DROP
|
||||||
|
`);
|
||||||
|
|
||||||
|
const CHUNK = 5000;
|
||||||
|
for (let i = 0; i < rows.length; i += CHUNK) {
|
||||||
|
const batch = rows.slice(i, i + CHUNK);
|
||||||
|
const placeholders = batch
|
||||||
|
.map((_, idx) => `($${idx * 3 + 1}, $${idx * 3 + 2}, $${idx * 3 + 3})`)
|
||||||
|
.join(',');
|
||||||
|
const values = batch.flatMap(r => [
|
||||||
|
r.pid,
|
||||||
|
r.notions_cost_each,
|
||||||
|
r.supplier_cost_each
|
||||||
|
]);
|
||||||
|
await localConnection.query(
|
||||||
|
`INSERT INTO temp_supplier_costs (pid, notions_cost_each, supplier_cost_each)
|
||||||
|
VALUES ${placeholders}
|
||||||
|
ON CONFLICT (pid) DO NOTHING`,
|
||||||
|
values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [result] = await localConnection.query(`
|
||||||
|
UPDATE products p
|
||||||
|
SET notions_cost_each = t.notions_cost_each,
|
||||||
|
supplier_cost_each = t.supplier_cost_each
|
||||||
|
FROM temp_supplier_costs t
|
||||||
|
WHERE p.pid = t.pid
|
||||||
|
AND (p.notions_cost_each IS DISTINCT FROM t.notions_cost_each
|
||||||
|
OR p.supplier_cost_each IS DISTINCT FROM t.supplier_cost_each)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const updated = result.rowCount || 0;
|
||||||
|
outputProgress({
|
||||||
|
status: "running",
|
||||||
|
operation: "Products import",
|
||||||
|
message: `Supplier costs synced for ${updated} products`
|
||||||
|
});
|
||||||
|
|
||||||
|
return { updated };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
importProducts,
|
importProducts,
|
||||||
importMissingProducts,
|
importMissingProducts,
|
||||||
setupTemporaryTables,
|
setupTemporaryTables,
|
||||||
cleanupTemporaryTables,
|
cleanupTemporaryTables,
|
||||||
materializeCalculations
|
materializeCalculations,
|
||||||
|
syncSupplierCosts
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,20 @@ import { extractBearerToken, verifyToken, TokenError } from './verify.js';
|
|||||||
|
|
||||||
const USER_CACHE_TTL_MS = 60_000;
|
const USER_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
// Source IPs that bypass token auth — used so the office kiosk can render
|
||||||
|
// /small without anyone having to log in daily on the device. Synthetic user
|
||||||
|
// has no permissions, so only endpoints that don't gate on requirePermission()
|
||||||
|
// are reachable. Requires server.js `trust proxy` setting so req.ip is the
|
||||||
|
// real client behind Caddy, not 127.0.0.1.
|
||||||
|
function parseKioskIps(raw) {
|
||||||
|
return new Set(
|
||||||
|
(raw || '')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createUserCache() {
|
function createUserCache() {
|
||||||
const entries = new Map();
|
const entries = new Map();
|
||||||
return {
|
return {
|
||||||
@@ -47,10 +61,23 @@ async function loadUser(pool, userId) {
|
|||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authenticate({ pool, secret = process.env.JWT_SECRET }) {
|
export function authenticate({ pool, secret = process.env.JWT_SECRET, kioskIps = process.env.KIOSK_IPS }) {
|
||||||
const cache = createUserCache();
|
const cache = createUserCache();
|
||||||
|
const kioskIpSet = parseKioskIps(kioskIps);
|
||||||
|
|
||||||
return async function authenticateMiddleware(req, res, next) {
|
return async function authenticateMiddleware(req, res, next) {
|
||||||
|
if (kioskIpSet.size > 0 && kioskIpSet.has(req.ip)) {
|
||||||
|
req.user = {
|
||||||
|
id: 'kiosk',
|
||||||
|
username: 'kiosk',
|
||||||
|
is_admin: false,
|
||||||
|
is_active: true,
|
||||||
|
permissions: [],
|
||||||
|
is_kiosk: true,
|
||||||
|
};
|
||||||
|
return next();
|
||||||
|
}
|
||||||
|
|
||||||
let decoded;
|
let decoded;
|
||||||
try {
|
try {
|
||||||
const token = extractBearerToken(req.headers.authorization);
|
const token = extractBearerToken(req.headers.authorization);
|
||||||
|
|||||||
@@ -126,6 +126,62 @@ describe('authenticate middleware', () => {
|
|||||||
expect(pool.calls.count).toBe(4);
|
expect(pool.calls.count).toBe(4);
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('KIOSK_IPS bypass', () => {
|
||||||
|
it('bypasses token check and mints a synthetic kiosk user when req.ip matches', async () => {
|
||||||
|
const pool = makeFakePool({});
|
||||||
|
const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7' });
|
||||||
|
const req = { headers: {}, ip: '203.0.113.7' };
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
await mw(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(req.user).toEqual({
|
||||||
|
id: 'kiosk',
|
||||||
|
username: 'kiosk',
|
||||||
|
is_admin: false,
|
||||||
|
is_active: true,
|
||||||
|
permissions: [],
|
||||||
|
is_kiosk: true,
|
||||||
|
});
|
||||||
|
expect(pool.calls.count).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to normal Bearer auth when req.ip is not in KIOSK_IPS', async () => {
|
||||||
|
const pool = makeFakePool({ 1: activeUser }, { 1: [] });
|
||||||
|
const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7' });
|
||||||
|
const req = { headers: { authorization: `Bearer ${validToken}` }, ip: '198.51.100.1' };
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
await mw(req, res, next);
|
||||||
|
expect(next).toHaveBeenCalledOnce();
|
||||||
|
expect(req.user.id).toBe(1);
|
||||||
|
expect(req.user.is_kiosk).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not bypass when KIOSK_IPS is empty, even if req.ip is undefined', async () => {
|
||||||
|
const pool = makeFakePool({ 1: activeUser });
|
||||||
|
const mw = authenticate({ pool, secret: SECRET, kioskIps: '' });
|
||||||
|
const req = { headers: {} };
|
||||||
|
const res = makeRes();
|
||||||
|
const next = vi.fn();
|
||||||
|
await mw(req, res, next);
|
||||||
|
expect(res.status).toHaveBeenCalledWith(401);
|
||||||
|
expect(next).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports multiple comma-separated IPs', async () => {
|
||||||
|
const pool = makeFakePool({});
|
||||||
|
const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7, 203.0.113.8 ,203.0.113.9' });
|
||||||
|
const next = vi.fn();
|
||||||
|
for (const ip of ['203.0.113.7', '203.0.113.8', '203.0.113.9']) {
|
||||||
|
const req = { headers: {}, ip };
|
||||||
|
await mw(req, makeRes(), next);
|
||||||
|
expect(req.user?.is_kiosk).toBe(true);
|
||||||
|
}
|
||||||
|
expect(next).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('requirePermission middleware', () => {
|
describe('requirePermission middleware', () => {
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export function requestLog(options = {}) {
|
|||||||
return {
|
return {
|
||||||
method: req.method,
|
method: req.method,
|
||||||
url: req.url,
|
url: req.url,
|
||||||
userId: req.raw?.user?.id,
|
userId: req.raw?.user?.id ?? req.user?.id,
|
||||||
|
ip: req.raw?.ip ?? req.ip,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
res(res) {
|
res(res) {
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ async function executeQuery(sql, params = []) {
|
|||||||
return pool.query(sql, params);
|
return pool.query(sql, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Identity probe for the small dashboard / kiosk flow. Lives under /api/dashboard/*
|
||||||
|
// so it sits behind Caddy's office-IP `client_ip` allowlist — the office kiosk can
|
||||||
|
// reach it without a token, while non-office requests must carry a valid Bearer.
|
||||||
|
// Reaches this handler only if shared `authenticate` middleware populated req.user.
|
||||||
|
router.get('/whoami', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
authenticated: !!req.user,
|
||||||
|
is_kiosk: !!req.user?.is_kiosk,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// GET /dashboard/stock/metrics
|
// GET /dashboard/stock/metrics
|
||||||
// Returns brand-level stock metrics
|
// Returns brand-level stock metrics
|
||||||
router.get('/stock/metrics', async (req, res) => {
|
router.get('/stock/metrics', async (req, res) => {
|
||||||
|
|||||||
@@ -496,7 +496,15 @@ router.get('/search', async (req, res) => {
|
|||||||
|
|
||||||
// Batch lookup of product display data by pid list (used by Create PO page)
|
// Batch lookup of product display data by pid list (used by Create PO page)
|
||||||
// Accepts ?pids=1,2,3 — comma-separated; de-duped server-side; capped at 500.
|
// Accepts ?pids=1,2,3 — comma-separated; de-duped server-side; capped at 500.
|
||||||
|
// Optional ?supplierId=<n> — when present, current_cost_price is computed via
|
||||||
|
// the same fallback chain the legacy PHP backend uses in clsPO::_product_add()
|
||||||
|
// (po.class.php:189-209):
|
||||||
|
// supplier_id == 92 (Notions) → products.notions_cost_each
|
||||||
|
// else / fallback → products.supplier_cost_each
|
||||||
|
// final fallback → most recent receivings.cost_each (>0)
|
||||||
// Returns rows in the same order as the deduped input pids; missing pids are silently dropped.
|
// Returns rows in the same order as the deduped input pids; missing pids are silently dropped.
|
||||||
|
const NOTIONS_SUPPLIER_ID = 92;
|
||||||
|
|
||||||
router.get('/batch', async (req, res) => {
|
router.get('/batch', async (req, res) => {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
const raw = req.query.pids;
|
const raw = req.query.pids;
|
||||||
@@ -514,6 +522,11 @@ router.get('/batch', async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'No valid pids provided' });
|
return res.status(400).json({ error: 'No valid pids provided' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const supplierIdRaw = req.query.supplierId;
|
||||||
|
const supplierId = supplierIdRaw != null && /^\d+$/.test(String(supplierIdRaw))
|
||||||
|
? parseInt(String(supplierIdRaw), 10)
|
||||||
|
: null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await pool.query(`
|
const { rows } = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -528,7 +541,17 @@ router.get('/batch', async (req, res) => {
|
|||||||
p.baskets,
|
p.baskets,
|
||||||
pm.on_order_qty,
|
pm.on_order_qty,
|
||||||
p.total_sold,
|
p.total_sold,
|
||||||
pm.current_cost_price,
|
p.notions_cost_each,
|
||||||
|
p.supplier_cost_each,
|
||||||
|
(
|
||||||
|
SELECT r.cost_each
|
||||||
|
FROM receivings r
|
||||||
|
WHERE r.pid = p.pid
|
||||||
|
AND r.cost_each > 0
|
||||||
|
AND r.status <> 'canceled'
|
||||||
|
ORDER BY r.received_date DESC
|
||||||
|
LIMIT 1
|
||||||
|
) AS last_received_cost,
|
||||||
pm.date_last_sold,
|
pm.date_last_sold,
|
||||||
pm.date_first_received,
|
pm.date_first_received,
|
||||||
p.moq
|
p.moq
|
||||||
@@ -537,10 +560,30 @@ router.get('/batch', async (req, res) => {
|
|||||||
WHERE p.pid = ANY($1::bigint[])
|
WHERE p.pid = ANY($1::bigint[])
|
||||||
`, [pids]);
|
`, [pids]);
|
||||||
|
|
||||||
|
const pickCost = (r) => {
|
||||||
|
// Treat 0 as "unset" the same way the PHP code does (`if (!$cost_each)`).
|
||||||
|
const notions = Number(r.notions_cost_each) || 0;
|
||||||
|
const supplier = Number(r.supplier_cost_each) || 0;
|
||||||
|
const lastReceived = Number(r.last_received_cost) || 0;
|
||||||
|
if (supplierId === NOTIONS_SUPPLIER_ID && notions > 0) return notions;
|
||||||
|
if (supplier > 0) return supplier;
|
||||||
|
if (lastReceived > 0) return lastReceived;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// products.pid is BIGINT, which the pg driver returns as a STRING by
|
// products.pid is BIGINT, which the pg driver returns as a STRING by
|
||||||
// default (to preserve precision for values > 2^53). Coerce to Number
|
// default (to preserve precision for values > 2^53). Coerce to Number
|
||||||
// so the JSON response has numeric pids and Map lookups work.
|
// so the JSON response has numeric pids and Map lookups work.
|
||||||
const normalized = rows.map(r => ({ ...r, pid: Number(r.pid) }));
|
const normalized = rows.map(r => ({
|
||||||
|
...r,
|
||||||
|
pid: Number(r.pid),
|
||||||
|
notions_cost_each: r.notions_cost_each != null ? Number(r.notions_cost_each) : null,
|
||||||
|
supplier_cost_each: r.supplier_cost_each != null ? Number(r.supplier_cost_each) : null,
|
||||||
|
last_received_cost: r.last_received_cost != null ? Number(r.last_received_cost) : null,
|
||||||
|
// current_cost_price = the value that will land on the PO if this product
|
||||||
|
// is added with the given supplier (or non-Notions default when omitted).
|
||||||
|
current_cost_price: pickCost(r),
|
||||||
|
}));
|
||||||
const byPid = new Map(normalized.map(r => [r.pid, r]));
|
const byPid = new Map(normalized.map(r => [r.pid, r]));
|
||||||
// Preserve the requested order so the frontend can append rows in input order
|
// Preserve the requested order so the frontend can append rows in input order
|
||||||
const ordered = pids.map(pid => byPid.get(pid)).filter(Boolean);
|
const ordered = pids.map(pid => byPid.get(pid)).filter(Boolean);
|
||||||
|
|||||||
@@ -967,6 +967,34 @@ router.get('/order-vs-received', async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get purchase order items
|
// Get purchase order items
|
||||||
|
// Lightweight lookup of a PO's supplier_id by po_id, used by the Create-PO
|
||||||
|
// page's "Add to existing PO" mode so we can compute the right cost-each
|
||||||
|
// fallback branch (Notions supplier 92 vs everyone else) without fetching
|
||||||
|
// the full PO contents.
|
||||||
|
router.get('/:id/supplier', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const { id } = req.params;
|
||||||
|
if (!id) {
|
||||||
|
return res.status(400).json({ error: 'Purchase order ID is required' });
|
||||||
|
}
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`SELECT supplier_id, vendor FROM purchase_orders WHERE po_id = $1 LIMIT 1`,
|
||||||
|
[String(id)]
|
||||||
|
);
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Purchase order not found' });
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
supplierId: rows[0].supplier_id != null ? Number(rows[0].supplier_id) : null,
|
||||||
|
vendor: rows[0].vendor || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching PO supplier:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch PO supplier' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/:id/items', async (req, res) => {
|
router.get('/:id/items', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const pool = req.app.locals.pool;
|
const pool = req.app.locals.pool;
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ requiredDirs.forEach((dir) => {
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Trust X-Forwarded-* only when the immediate hop is loopback (Caddy on the same
|
||||||
|
// host). Anything stricter would leave req.ip as 127.0.0.1; anything looser would
|
||||||
|
// let arbitrary clients spoof their source IP via X-Forwarded-For. Required for
|
||||||
|
// the KIOSK_IPS bypass in shared/auth/middleware.js to match real client IPs.
|
||||||
|
app.set('trust proxy', 'loopback');
|
||||||
|
|
||||||
// Phase 6.5/6.9: structured access log (replaces the previous header-dumping debug
|
// Phase 6.5/6.9: structured access log (replaces the previous header-dumping debug
|
||||||
// middleware that wrote raw Authorization values to stdout). Pino redaction strips
|
// middleware that wrote raw Authorization values to stdout). Pino redaction strips
|
||||||
// `authorization` and `cookie` automatically — see shared/logging/logger.js.
|
// `authorization` and `cookie` automatically — see shared/logging/logger.js.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { X as XIcon } from "lucide-react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { formatCurrency, formatNumber, formatDateShort } from "@/utils/productUtils";
|
import { formatCurrency, formatNumber, formatDateShort } from "@/utils/productUtils";
|
||||||
import type { PoLineItem } from "./types";
|
import type { PoLineItem } from "./types";
|
||||||
|
import { computeCostEach } from "./types";
|
||||||
import { NOTIONS_SUPPLIER_ID } from "./constants";
|
import { NOTIONS_SUPPLIER_ID } from "./constants";
|
||||||
|
|
||||||
type SortKey =
|
type SortKey =
|
||||||
@@ -110,7 +111,7 @@ export function LineItemsTable({
|
|||||||
baskets: (i) => i.baskets,
|
baskets: (i) => i.baskets,
|
||||||
on_order_qty: (i) => i.on_order_qty,
|
on_order_qty: (i) => i.on_order_qty,
|
||||||
total_sold: (i) => i.total_sold,
|
total_sold: (i) => i.total_sold,
|
||||||
current_cost_price: (i) => i.current_cost_price,
|
current_cost_price: (i) => computeCostEach(i, supplierId),
|
||||||
date_last_sold: (i) => i.date_last_sold,
|
date_last_sold: (i) => i.date_last_sold,
|
||||||
date_first_received: (i) => i.date_first_received,
|
date_first_received: (i) => i.date_first_received,
|
||||||
notions_inv_count: (i) => i.notions_inv_count,
|
notions_inv_count: (i) => i.notions_inv_count,
|
||||||
@@ -119,7 +120,7 @@ export function LineItemsTable({
|
|||||||
};
|
};
|
||||||
const accessor = accessors[sortKey];
|
const accessor = accessors[sortKey];
|
||||||
return [...items].sort((a, b) => compareValues(accessor(a), accessor(b), sortDir));
|
return [...items].sort((a, b) => compareValues(accessor(a), accessor(b), sortDir));
|
||||||
}, [items, sortKey, sortDir, isNotions]);
|
}, [items, sortKey, sortDir, isNotions, supplierId]);
|
||||||
|
|
||||||
const handleSort = useCallback(
|
const handleSort = useCallback(
|
||||||
(key: SortKey) => {
|
(key: SortKey) => {
|
||||||
@@ -316,9 +317,10 @@ export function LineItemsTable({
|
|||||||
{item.total_sold != null ? formatNumber(item.total_sold) : "—"}
|
{item.total_sold != null ? formatNumber(item.total_sold) : "—"}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center whitespace-nowrap">
|
<TableCell className="text-center whitespace-nowrap">
|
||||||
{item.current_cost_price != null
|
{(() => {
|
||||||
? formatCurrency(item.current_cost_price)
|
const cost = computeCostEach(item, supplierId);
|
||||||
: "—"}
|
return cost != null ? formatCurrency(cost) : "—";
|
||||||
|
})()}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs whitespace-nowrap text-center">
|
<TableCell className="text-xs whitespace-nowrap text-center">
|
||||||
{item.date_last_sold ? formatDateShort(item.date_last_sold) : "—"}
|
{item.date_last_sold ? formatDateShort(item.date_last_sold) : "—"}
|
||||||
|
|||||||
@@ -106,12 +106,20 @@ export async function resolveIdentifiers(
|
|||||||
* to BATCH_LOOKUP_MAX_PIDS to stay under URL length limits even if the
|
* to BATCH_LOOKUP_MAX_PIDS to stay under URL length limits even if the
|
||||||
* caller passes hundreds of pids.
|
* caller passes hundreds of pids.
|
||||||
*
|
*
|
||||||
|
* When `supplierId` is provided, the backend computes `current_cost_price`
|
||||||
|
* using the same fallback chain the legacy PHP `_product_add` writes onto
|
||||||
|
* the PO line item (notions_cost_each / supplier_cost_each / receivings).
|
||||||
|
* The three raw cost fields (`notions_cost_each`, `supplier_cost_each`,
|
||||||
|
* `last_received_cost`) are always returned so the UI can re-derive cost
|
||||||
|
* for a different supplier without a round-trip.
|
||||||
|
*
|
||||||
* Returns a flat array of PoLineItem with `qty` set to the value passed in
|
* Returns a flat array of PoLineItem with `qty` set to the value passed in
|
||||||
* the `qtyByPid` map (default 1).
|
* the `qtyByPid` map (default 1).
|
||||||
*/
|
*/
|
||||||
export async function fetchBatchProducts(
|
export async function fetchBatchProducts(
|
||||||
pids: number[],
|
pids: number[],
|
||||||
qtyByPid: Map<number, number> = new Map()
|
qtyByPid: Map<number, number> = new Map(),
|
||||||
|
supplierId?: number
|
||||||
): Promise<PoLineItem[]> {
|
): Promise<PoLineItem[]> {
|
||||||
if (pids.length === 0) return [];
|
if (pids.length === 0) return [];
|
||||||
|
|
||||||
@@ -120,9 +128,13 @@ export async function fetchBatchProducts(
|
|||||||
|
|
||||||
for (let i = 0; i < uniqPids.length; i += BATCH_LOOKUP_MAX_PIDS) {
|
for (let i = 0; i < uniqPids.length; i += BATCH_LOOKUP_MAX_PIDS) {
|
||||||
const chunk = uniqPids.slice(i, i + BATCH_LOOKUP_MAX_PIDS);
|
const chunk = uniqPids.slice(i, i + BATCH_LOOKUP_MAX_PIDS);
|
||||||
|
const params: Record<string, string> = { pids: chunk.join(",") };
|
||||||
|
if (supplierId != null && Number.isFinite(supplierId)) {
|
||||||
|
params.supplierId = String(supplierId);
|
||||||
|
}
|
||||||
const res = await apiClient.get<Omit<PoLineItem, "qty">[]>(
|
const res = await apiClient.get<Omit<PoLineItem, "qty">[]>(
|
||||||
"/api/products/batch",
|
"/api/products/batch",
|
||||||
{ params: { pids: chunk.join(",") } }
|
{ params }
|
||||||
);
|
);
|
||||||
for (const row of res.data ?? []) {
|
for (const row of res.data ?? []) {
|
||||||
// Defensive Number() coercion: the backend already returns pid as a
|
// Defensive Number() coercion: the backend already returns pid as a
|
||||||
|
|||||||
@@ -1,3 +1,33 @@
|
|||||||
|
import { NOTIONS_SUPPLIER_ID } from "./constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the "cost each" that the PO line item will carry, mirroring the
|
||||||
|
* fallback chain in the legacy PHP `clsPO::_product_add()` (po.class.php:189):
|
||||||
|
*
|
||||||
|
* if supplierId == 92 (Notions):
|
||||||
|
* notions_cost_each → supplier_cost_each → last_received_cost
|
||||||
|
* else:
|
||||||
|
* supplier_cost_each → last_received_cost
|
||||||
|
*
|
||||||
|
* Returns null if none of the candidate fields has a positive value, so
|
||||||
|
* callers can distinguish "no quote" from "$0".
|
||||||
|
*
|
||||||
|
* Computed client-side so changing the supplier mid-flow doesn't require a
|
||||||
|
* round-trip; the three raw fields come from /api/products/batch once.
|
||||||
|
*/
|
||||||
|
export function computeCostEach(
|
||||||
|
item: Pick<PoLineItem, "notions_cost_each" | "supplier_cost_each" | "last_received_cost">,
|
||||||
|
supplierId: number | undefined
|
||||||
|
): number | null {
|
||||||
|
const notions = Number(item.notions_cost_each) || 0;
|
||||||
|
const supplier = Number(item.supplier_cost_each) || 0;
|
||||||
|
const lastReceived = Number(item.last_received_cost) || 0;
|
||||||
|
if (Number(supplierId) === NOTIONS_SUPPLIER_ID && notions > 0) return notions;
|
||||||
|
if (supplier > 0) return supplier;
|
||||||
|
if (lastReceived > 0) return lastReceived;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display shape for a single line item on the Create PO page. This is the
|
* Display shape for a single line item on the Create PO page. This is the
|
||||||
* exact response shape returned by GET /api/products/batch (snake_case).
|
* exact response shape returned by GET /api/products/batch (snake_case).
|
||||||
@@ -17,7 +47,23 @@ export interface PoLineItem {
|
|||||||
baskets: number | null;
|
baskets: number | null;
|
||||||
on_order_qty: number | null;
|
on_order_qty: number | null;
|
||||||
total_sold: number | null;
|
total_sold: number | null;
|
||||||
|
/**
|
||||||
|
* Backend-computed cost using the same fallback chain the legacy PHP
|
||||||
|
* `_product_add()` writes onto the PO line item:
|
||||||
|
* supplier=92 → notions_cost_each → supplier_cost_each → last received cost
|
||||||
|
* else → supplier_cost_each → last received cost
|
||||||
|
* Sent by the server when a supplierId is passed to /api/products/batch.
|
||||||
|
* For supplier-aware display the UI should re-compute from the three raw
|
||||||
|
* fields below using `computeCostEach()`, since the working supplier can
|
||||||
|
* change after the row was fetched.
|
||||||
|
*/
|
||||||
current_cost_price: number | null;
|
current_cost_price: number | null;
|
||||||
|
/** Raw cost from supplier_item_data.notions_cost_each (0/null when unset). */
|
||||||
|
notions_cost_each: number | null;
|
||||||
|
/** Raw cost from supplier_item_data.supplier_cost_each (0/null when unset). */
|
||||||
|
supplier_cost_each: number | null;
|
||||||
|
/** Most recent receivings.cost_each (>0, non-canceled). */
|
||||||
|
last_received_cost: number | null;
|
||||||
date_last_sold: string | null;
|
date_last_sold: string | null;
|
||||||
date_first_received: string | null;
|
date_first_received: string | null;
|
||||||
/** From the products table; may be null/0/inconsistent. The user can override locally. */
|
/** From the products table; may be null/0/inconsistent. The user can override locally. */
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
* dedup against the target PO (we don't fetch its current contents).
|
* dedup against the target PO (we don't fetch its current contents).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -46,6 +46,8 @@ import { AddProductsDialog } from "@/components/create-po/AddProductsDialog";
|
|||||||
import { ConfirmationView } from "@/components/create-po/ConfirmationView";
|
import { ConfirmationView } from "@/components/create-po/ConfirmationView";
|
||||||
import { fetchBatchProducts } from "@/components/create-po/resolveIdentifiers";
|
import { fetchBatchProducts } from "@/components/create-po/resolveIdentifiers";
|
||||||
import type { PoLineItem } from "@/components/create-po/types";
|
import type { PoLineItem } from "@/components/create-po/types";
|
||||||
|
import { computeCostEach } from "@/components/create-po/types";
|
||||||
|
import { apiClient } from "@/utils/apiClient";
|
||||||
import {
|
import {
|
||||||
submitNewPurchaseOrder,
|
submitNewPurchaseOrder,
|
||||||
addProductsToPurchaseOrder,
|
addProductsToPurchaseOrder,
|
||||||
@@ -62,6 +64,10 @@ export default function CreatePurchaseOrder() {
|
|||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [hydrating, setHydrating] = useState(false);
|
const [hydrating, setHydrating] = useState(false);
|
||||||
|
// Supplier of the existing PO in "add" mode (looked up from /api/purchase-orders/:id/supplier).
|
||||||
|
// Drives the Notions branch of the cost fallback so the displayed cost matches what the legacy
|
||||||
|
// backend will actually stamp onto the line item.
|
||||||
|
const [existingPoSupplierId, setExistingPoSupplierId] = useState<number | undefined>(undefined);
|
||||||
const [confirmation, setConfirmation] = useState<{
|
const [confirmation, setConfirmation] = useState<{
|
||||||
poId: number;
|
poId: number;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
@@ -77,6 +83,7 @@ export default function CreatePurchaseOrder() {
|
|||||||
setMode(next);
|
setMode(next);
|
||||||
setSupplierId(undefined);
|
setSupplierId(undefined);
|
||||||
setExistingPoInput("");
|
setExistingPoInput("");
|
||||||
|
setExistingPoSupplierId(undefined);
|
||||||
setLineItems([]);
|
setLineItems([]);
|
||||||
setSelectedPids(new Set());
|
setSelectedPids(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
@@ -106,7 +113,15 @@ export default function CreatePurchaseOrder() {
|
|||||||
const qtyByPid = new Map(fresh.map((i) => [i.pid, i.qty]));
|
const qtyByPid = new Map(fresh.map((i) => [i.pid, i.qty]));
|
||||||
const hydrated = await fetchBatchProducts(
|
const hydrated = await fetchBatchProducts(
|
||||||
fresh.map((i) => i.pid),
|
fresh.map((i) => i.pid),
|
||||||
qtyByPid
|
qtyByPid,
|
||||||
|
// Tell the backend which fallback branch to use for the server-side
|
||||||
|
// current_cost_price field. The UI also re-computes locally via
|
||||||
|
// computeCostEach() if the supplier changes mid-flow.
|
||||||
|
mode === "create"
|
||||||
|
? supplierId
|
||||||
|
? Number(supplierId)
|
||||||
|
: undefined
|
||||||
|
: existingPoSupplierId
|
||||||
);
|
);
|
||||||
if (hydrated.length === 0) {
|
if (hydrated.length === 0) {
|
||||||
toast.error("Could not load product details");
|
toast.error("Could not load product details");
|
||||||
@@ -126,7 +141,7 @@ export default function CreatePurchaseOrder() {
|
|||||||
setHydrating(false);
|
setHydrating(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[lineItems]
|
[lineItems, mode, supplierId, existingPoSupplierId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Row mutation handlers (passed to LineItemsTable) --------------------
|
// ---- Row mutation handlers (passed to LineItemsTable) --------------------
|
||||||
@@ -202,6 +217,43 @@ export default function CreatePurchaseOrder() {
|
|||||||
|
|
||||||
const targetReady = mode === "create" ? !!supplierId : parsedPoId !== undefined;
|
const targetReady = mode === "create" ? !!supplierId : parsedPoId !== undefined;
|
||||||
|
|
||||||
|
// Effective supplier driving the cost-each fallback chain.
|
||||||
|
// - create mode → the supplier the user just picked
|
||||||
|
// - add mode → the supplier of the existing PO (looked up below)
|
||||||
|
const effectiveSupplierId =
|
||||||
|
mode === "create"
|
||||||
|
? supplierId
|
||||||
|
? Number(supplierId)
|
||||||
|
: undefined
|
||||||
|
: existingPoSupplierId;
|
||||||
|
|
||||||
|
// ---- Look up existing PO supplier in "add" mode ---------------------------
|
||||||
|
// The cost-each fallback branches on whether the PO is for Notions (supplier 92),
|
||||||
|
// so we need to know the target PO's supplier as soon as the user types a valid id.
|
||||||
|
// Debounced via parsedPoId so we don't fire a request on every keystroke.
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== "add" || parsedPoId === undefined) {
|
||||||
|
setExistingPoSupplierId(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
apiClient
|
||||||
|
.get<{ supplierId: number | null }>(
|
||||||
|
`/api/purchase-orders/${parsedPoId}/supplier`
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const s = res.data?.supplierId;
|
||||||
|
setExistingPoSupplierId(typeof s === "number" && s > 0 ? s : undefined);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setExistingPoSupplierId(undefined);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mode, parsedPoId]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(async () => {
|
const handleSubmit = useCallback(async () => {
|
||||||
if (mode === "create" && !supplierId) {
|
if (mode === "create" && !supplierId) {
|
||||||
toast.error("Pick a supplier first");
|
toast.error("Pick a supplier first");
|
||||||
@@ -267,6 +319,7 @@ export default function CreatePurchaseOrder() {
|
|||||||
const handleCreateAnother = useCallback(() => {
|
const handleCreateAnother = useCallback(() => {
|
||||||
setSupplierId(undefined);
|
setSupplierId(undefined);
|
||||||
setExistingPoInput("");
|
setExistingPoInput("");
|
||||||
|
setExistingPoSupplierId(undefined);
|
||||||
setLineItems([]);
|
setLineItems([]);
|
||||||
setSelectedPids(new Set());
|
setSelectedPids(new Set());
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
@@ -288,9 +341,11 @@ export default function CreatePurchaseOrder() {
|
|||||||
|
|
||||||
// ---- Builder view ---------------------------------------------------------
|
// ---- Builder view ---------------------------------------------------------
|
||||||
const totalQty = lineItems.reduce((sum, i) => sum + (i.qty > 0 ? i.qty : 0), 0);
|
const totalQty = lineItems.reduce((sum, i) => sum + (i.qty > 0 ? i.qty : 0), 0);
|
||||||
|
// Use the supplier-aware fallback (same chain the legacy PHP backend uses)
|
||||||
|
// so the displayed total reflects what will land on the PO.
|
||||||
const totalCost = lineItems.reduce(
|
const totalCost = lineItems.reduce(
|
||||||
(sum, i) =>
|
(sum, i) =>
|
||||||
sum + (i.qty > 0 ? i.qty * (i.current_cost_price ?? 0) : 0),
|
sum + (i.qty > 0 ? i.qty * (computeCostEach(i, effectiveSupplierId) ?? 0) : 0),
|
||||||
0
|
0
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -379,7 +434,7 @@ export default function CreatePurchaseOrder() {
|
|||||||
<LineItemsTable
|
<LineItemsTable
|
||||||
items={lineItems}
|
items={lineItems}
|
||||||
selectedPids={selectedPids}
|
selectedPids={selectedPids}
|
||||||
supplierId={mode === "create" && supplierId ? Number(supplierId) : undefined}
|
supplierId={effectiveSupplierId}
|
||||||
onToggleSelect={handleToggleSelect}
|
onToggleSelect={handleToggleSelect}
|
||||||
onToggleSelectAll={handleToggleSelectAll}
|
onToggleSelectAll={handleToggleSelectAll}
|
||||||
onChangeQty={handleChangeQty}
|
onChangeQty={handleChangeQty}
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ export default function ProductEditor() {
|
|||||||
if (products.length === 0) return;
|
if (products.length === 0) return;
|
||||||
const pids = products.map((p) => p.pid);
|
const pids = products.map((p) => p.pid);
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
axios
|
apiClient
|
||||||
.get("/api/import/product-images-batch", {
|
.get("/api/import/product-images-batch", {
|
||||||
params: { pids: pids.join(",") },
|
params: { pids: pids.join(",") },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -223,7 +223,7 @@ export default function ProductEditor() {
|
|||||||
}, [products]);
|
}, [products]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
axios
|
apiClient
|
||||||
.get("/api/import/field-options")
|
.get("/api/import/field-options")
|
||||||
.then((res) => setFieldOptions(res.data))
|
.then((res) => setFieldOptions(res.data))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -243,7 +243,7 @@ export default function ProductEditor() {
|
|||||||
setSublineOptions([]);
|
setSublineOptions([]);
|
||||||
if (!lineCompany) return;
|
if (!lineCompany) return;
|
||||||
setIsLoadingLines(true);
|
setIsLoadingLines(true);
|
||||||
axios
|
apiClient
|
||||||
.get(`/api/import/product-lines/${lineCompany}`)
|
.get(`/api/import/product-lines/${lineCompany}`)
|
||||||
.then((res) => setLineOptions(res.data))
|
.then((res) => setLineOptions(res.data))
|
||||||
.catch(() => setLineOptions([]))
|
.catch(() => setLineOptions([]))
|
||||||
@@ -256,7 +256,7 @@ export default function ProductEditor() {
|
|||||||
setSublineOptions([]);
|
setSublineOptions([]);
|
||||||
if (!lineLine) return;
|
if (!lineLine) return;
|
||||||
setIsLoadingSublines(true);
|
setIsLoadingSublines(true);
|
||||||
axios
|
apiClient
|
||||||
.get(`/api/import/sublines/${lineLine}`)
|
.get(`/api/import/sublines/${lineLine}`)
|
||||||
.then((res) => setSublineOptions(res.data))
|
.then((res) => setSublineOptions(res.data))
|
||||||
.catch(() => setSublineOptions([]))
|
.catch(() => setSublineOptions([]))
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider";
|
import { ThemeProvider } from "@/components/dashboard/theme/ThemeProvider";
|
||||||
import LockButton from "@/components/dashboard/LockButton";
|
import LockButton from "@/components/dashboard/LockButton";
|
||||||
import PinProtection from "@/components/dashboard/PinProtection";
|
import PinProtection from "@/components/dashboard/PinProtection";
|
||||||
@@ -10,6 +11,8 @@ import MiniEventFeed from "@/components/dashboard/MiniEventFeed";
|
|||||||
import MiniBusinessMetrics from "@/components/dashboard/MiniBusinessMetrics";
|
import MiniBusinessMetrics from "@/components/dashboard/MiniBusinessMetrics";
|
||||||
// @ts-expect-error - JSX component without type declarations
|
// @ts-expect-error - JSX component without type declarations
|
||||||
import MiniInventorySnapshot from "@/components/dashboard/MiniInventorySnapshot";
|
import MiniInventorySnapshot from "@/components/dashboard/MiniInventorySnapshot";
|
||||||
|
import PageLoading from "@/components/ui/page-loading";
|
||||||
|
import { apiFetch } from "@/utils/api";
|
||||||
|
|
||||||
// Pin Protected Layout
|
// Pin Protected Layout
|
||||||
const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
|
const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
@@ -29,6 +32,41 @@ const PinProtectedLayout = ({ children }: { children: React.ReactNode }) => {
|
|||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Three-way gate: office IP gets PIN, authenticated users skip PIN,
|
||||||
|
// everyone else is bounced to login. Identity comes from /api/dashboard/whoami,
|
||||||
|
// which sits behind Caddy's office-IP allowlist for the kiosk case.
|
||||||
|
type Identity = "probing" | "kiosk" | "authenticated" | "anonymous";
|
||||||
|
|
||||||
|
const AccessGate = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const [identity, setIdentity] = useState<Identity>("probing");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/dashboard/whoami");
|
||||||
|
if (cancelled) return;
|
||||||
|
if (res.status === 401) {
|
||||||
|
setIdentity("anonymous");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await res.json();
|
||||||
|
setIdentity(body.is_kiosk ? "kiosk" : "authenticated");
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setIdentity("anonymous");
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (identity === "probing") return <PageLoading />;
|
||||||
|
if (identity === "anonymous") return <Navigate to="/login?redirect=/small" replace />;
|
||||||
|
if (identity === "kiosk") return <PinProtectedLayout>{children}</PinProtectedLayout>;
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
// Small Layout
|
// Small Layout
|
||||||
const SmallLayout = () => {
|
const SmallLayout = () => {
|
||||||
const DATETIME_SCALE = 2;
|
const DATETIME_SCALE = 2;
|
||||||
@@ -130,9 +168,9 @@ const SmallLayout = () => {
|
|||||||
export function SmallDashboard() {
|
export function SmallDashboard() {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<PinProtectedLayout>
|
<AccessGate>
|
||||||
<SmallLayout />
|
<SmallLayout />
|
||||||
</PinProtectedLayout>
|
</AccessGate>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,12 @@ export interface CreateProductCategoryResponse {
|
|||||||
category?: unknown;
|
category?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always use relative URLs - proxied by Vite in dev and Caddy in production
|
// Relative URLs — same-origin to the browser. In production, Caddy on
|
||||||
// Frontend calls /apiv2/* -> Caddy transforms to /api/* -> proxies to www.acherryontop.com
|
// tools.acherryontop.com reverse-proxies /apiv2/* to backend.acherryontop.com
|
||||||
|
// (and /apiv2-test/* to work-test-backend.acherryontop.com via Vite in dev).
|
||||||
|
// The prod endpoints rely on a session cookie scoped to `.acherryontop.com`
|
||||||
|
// (sent via credentials: 'include' below); the dev/test endpoint instead
|
||||||
|
// receives an auth token in the request body (VITE_APIV2_AUTH_TOKEN).
|
||||||
const DEV_ENDPOINT = "/apiv2-test/product/setup_new";
|
const DEV_ENDPOINT = "/apiv2-test/product/setup_new";
|
||||||
const DEV_CREATE_CATEGORY_ENDPOINT = "/apiv2-test/prod_cat/new";
|
const DEV_CREATE_CATEGORY_ENDPOINT = "/apiv2-test/prod_cat/new";
|
||||||
const PROD_ENDPOINT = "/apiv2/product/setup_new";
|
const PROD_ENDPOINT = "/apiv2/product/setup_new";
|
||||||
|
|||||||
Reference in New Issue
Block a user