Compare commits
2 Commits
8c707e28ea
...
9ff744399f
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ff744399f | |||
| 3e38d0e5ce |
@@ -0,0 +1,175 @@
|
||||
-- Permissions UI cleanup — 2026-05-28
|
||||
--
|
||||
-- WHAT THIS DOES
|
||||
-- Rewrites permissions.name and permissions.category for clarity.
|
||||
-- Consolidates 17 categories down to 10. Renames ambiguous entries so
|
||||
-- the User Management UI reads cleanly. Does NOT touch permissions.code,
|
||||
-- so every existing route gate (backend requirePermission and frontend
|
||||
-- Protected page=) and every row in user_permissions keeps working
|
||||
-- without any code change or remapping.
|
||||
--
|
||||
-- WHAT THIS DOES *NOT* DO
|
||||
-- No DROP/DELETE of any permission (except in the optional block at the
|
||||
-- very bottom — commented out by default). No changes to permissions.code.
|
||||
-- No INSERT of new permissions. No changes to other tables.
|
||||
--
|
||||
-- SAFETY
|
||||
-- Wrapped in a transaction. Run end-to-end; if any row count is wrong,
|
||||
-- ROLLBACK and inspect. The auth middleware caches the user's loaded
|
||||
-- permissions for 60s — after this runs, names refresh on next cache
|
||||
-- miss for the admin UI. user_permissions joins by id, so granted
|
||||
-- permissions remain granted across renames.
|
||||
|
||||
BEGIN;
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 1. Category consolidation
|
||||
------------------------------------------------------------------------
|
||||
|
||||
-- 1a. Orphan "Pages" (just the Settings page) → Pages/Settings
|
||||
UPDATE permissions SET category = 'Pages/Settings'
|
||||
WHERE code = 'access:settings';
|
||||
|
||||
-- 1b. Existing settings:* tab-access permissions stay under a renamed
|
||||
-- "Settings Tabs" category (was "Settings") so it reads less ambiguously
|
||||
-- next to "Pages/Settings" and "Write Actions".
|
||||
UPDATE permissions SET category = 'Settings Tabs'
|
||||
WHERE category = 'Settings';
|
||||
|
||||
-- 1c. Dashboard widget perms keep their codes but get a clearer category name.
|
||||
UPDATE permissions SET category = 'Dashboard Widgets'
|
||||
WHERE category = 'Dashboard Components';
|
||||
|
||||
-- 1d. Collapse the 7 single-member "new permission" categories
|
||||
-- (Imports, Data, AI, Templates, Images, Audit, ACOT, Dashboard
|
||||
-- [the dashboard-server one — distinct from Pages/Dashboard])
|
||||
-- into a single "Write Actions" category.
|
||||
UPDATE permissions SET category = 'Write Actions'
|
||||
WHERE category IN ('Imports', 'Data', 'AI', 'Templates',
|
||||
'Images', 'Audit', 'ACOT', 'Dashboard');
|
||||
|
||||
-- 1e. The "Admin" category (just Show Debug) stays as-is — single member
|
||||
-- but conceptually distinct enough that bucketing under Write Actions
|
||||
-- would muddy the meaning. Leaving it alone.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 2. Name renames for clarity
|
||||
------------------------------------------------------------------------
|
||||
|
||||
-- 2a. Distinguish "page that lets you do X" from "permission to do X"
|
||||
-- by suffixing the page-access permissions with " (page)" where the
|
||||
-- name otherwise collides with the corresponding write permission.
|
||||
UPDATE permissions SET name = 'Import Products (page)'
|
||||
WHERE code = 'access:import';
|
||||
|
||||
UPDATE permissions SET name = 'Product Editor (page)'
|
||||
WHERE code = 'access:product_editor';
|
||||
|
||||
UPDATE permissions SET name = 'Bulk Edit (page)'
|
||||
WHERE code = 'access:bulk_edit';
|
||||
|
||||
-- 2b. Settings tab-access perms get a uniform "Settings: X Tab" name so
|
||||
-- they sort together and read as "what you're seeing access to" not
|
||||
-- "the feature itself."
|
||||
UPDATE permissions SET name = 'Settings: Data Management Tab'
|
||||
WHERE code = 'settings:data_management';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Reusable Images Tab'
|
||||
WHERE code = 'settings:library_management';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: AI Prompts Tab'
|
||||
WHERE code = 'settings:prompt_management';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Templates Tab'
|
||||
WHERE code = 'settings:templates';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: User Management Tab'
|
||||
WHERE code = 'settings:user_management';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Audit Log Tab'
|
||||
WHERE code = 'settings:audit_log';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Global Tab'
|
||||
WHERE code = 'settings:global';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Products Tab'
|
||||
WHERE code = 'settings:products';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Vendors Tab'
|
||||
WHERE code = 'settings:vendors';
|
||||
|
||||
-- 2c. Write-action perms get verb-leading names so it's obvious what
|
||||
-- granting them actually allows.
|
||||
UPDATE permissions
|
||||
SET name = 'Product Import: Upload & Submit',
|
||||
description = 'Allows POST/PUT/DELETE on /api/import — image uploads, '
|
||||
|| 'product submission, deletions, and generate-upc. Does NOT '
|
||||
|| 'grant access to the Import Products page (access:import).'
|
||||
WHERE code = 'product_import';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Data Management: Run Operations',
|
||||
description = 'Allows POST/PUT/DELETE on /api/csv — CSV operations, '
|
||||
|| 'full updates, full resets. Does NOT grant access to the '
|
||||
|| 'Data Management settings tab (settings:data_management).'
|
||||
WHERE code = 'data_management';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Reusable Images: Upload & Delete',
|
||||
description = 'Allows uploads and deletions on /api/reusable-images. '
|
||||
|| 'Distinct from product_import (which gates uploads inside '
|
||||
|| 'the product import flow).'
|
||||
WHERE code = 'image_admin';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Templates: Create & Edit',
|
||||
description = 'Allows POST/PUT/DELETE on /api/templates.'
|
||||
WHERE code = 'templates_write';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'AI: Edit Prompts & Validation',
|
||||
description = 'Allows write access to /api/ai-prompts and /api/ai-validation.'
|
||||
WHERE code = 'ai_admin';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Klaviyo: Clear Cache',
|
||||
description = 'Allows POST /api/klaviyo/events/clearCache.'
|
||||
WHERE code = 'klaviyo_admin';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Meta: Mutate Campaigns',
|
||||
description = 'Allows PATCH/POST on /api/meta/campaigns/*.'
|
||||
WHERE code = 'meta_write';
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 3. Verification — should all return non-zero
|
||||
------------------------------------------------------------------------
|
||||
-- Uncomment to inspect before commit:
|
||||
-- SELECT category, COUNT(*) FROM permissions GROUP BY category ORDER BY category;
|
||||
-- SELECT code, name, category FROM permissions
|
||||
-- WHERE category IN ('Write Actions', 'Settings Tabs', 'Pages/Settings')
|
||||
-- ORDER BY category, name;
|
||||
|
||||
COMMIT;
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 4. OPTIONAL — drop unused "Reserved for future" codes
|
||||
------------------------------------------------------------------------
|
||||
-- These five codes are referenced only in their own description ("Reserved
|
||||
-- for…") and appear in NO route gate, NO Protected page=, and NO frontend
|
||||
-- permissions.includes() check. Verified 2026-05-28.
|
||||
--
|
||||
-- Run this block separately if you want to drop them. user_permissions has
|
||||
-- ON DELETE CASCADE on permission_id is NOT configured (only on user_id),
|
||||
-- so we must clear user_permissions rows first.
|
||||
--
|
||||
-- BEGIN;
|
||||
-- DELETE FROM user_permissions
|
||||
-- WHERE permission_id IN (SELECT id FROM permissions
|
||||
-- WHERE code IN ('klaviyo_write', 'google_write',
|
||||
-- 'typeform_write', 'acot_admin',
|
||||
-- 'audit_read'));
|
||||
-- DELETE FROM permissions
|
||||
-- WHERE code IN ('klaviyo_write', 'google_write', 'typeform_write',
|
||||
-- 'acot_admin', 'audit_read');
|
||||
-- COMMIT;
|
||||
@@ -66,7 +66,16 @@ export function authenticate({ pool, secret = process.env.JWT_SECRET, kioskIps =
|
||||
const kioskIpSet = parseKioskIps(kioskIps);
|
||||
|
||||
return async function authenticateMiddleware(req, res, next) {
|
||||
if (kioskIpSet.size > 0 && kioskIpSet.has(req.ip)) {
|
||||
// Kiosk IP bypass ONLY when no Authorization header was provided. A real
|
||||
// user on the same network (e.g. logged-in staff sharing the office NAT)
|
||||
// must keep their actual identity and permissions — otherwise this bypass
|
||||
// silently downgrades them to the permissionless kiosk user and they get
|
||||
// 403 on every gated route.
|
||||
if (
|
||||
kioskIpSet.size > 0 &&
|
||||
kioskIpSet.has(req.ip) &&
|
||||
!req.headers.authorization
|
||||
) {
|
||||
req.user = {
|
||||
id: 'kiosk',
|
||||
username: 'kiosk',
|
||||
|
||||
@@ -159,6 +159,25 @@ describe('authenticate middleware', () => {
|
||||
expect(req.user.is_kiosk).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT bypass when a Bearer token is present, even from a kiosk IP', async () => {
|
||||
// A real user logged in from the same NAT'd network as the kiosk must
|
||||
// keep their actual identity — otherwise the bypass silently strips
|
||||
// their permissions and they 403 on gated routes.
|
||||
const pool = makeFakePool({ 1: activeUser }, { 1: ['product_import'] });
|
||||
const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7' });
|
||||
const req = {
|
||||
headers: { authorization: `Bearer ${validToken}` },
|
||||
ip: '203.0.113.7',
|
||||
};
|
||||
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();
|
||||
expect(req.user.permissions).toEqual(['product_import']);
|
||||
});
|
||||
|
||||
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: '' });
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// apiv2-bridge — a SIDE-SERVICE that lets external apps post to the legacy PHP
|
||||
// `/apiv2/*` API without a browser cookie.
|
||||
//
|
||||
// This is an ISOLATED, ADDITIVE router. It does not touch, wrap, or sit in the
|
||||
// path of any existing flow:
|
||||
// - The browser frontend keeps hitting Caddy's `/apiv2` -> PHP directly with
|
||||
// its own session cookie. Unchanged.
|
||||
// - This router is mounted at `/api/apiv2-bridge` (a deliberately distinct
|
||||
// prefix that cannot be confused with the real `/apiv2`). It inherits the
|
||||
// same user-JWT auth every `/api/*` route already enforces — the same token
|
||||
// the product-import skill already sends.
|
||||
//
|
||||
// Flow: external app --(user JWT)--> /api/apiv2-bridge/<subpath>
|
||||
// -> acotBackendSession swaps in a server-held PHP cookie
|
||||
// -> POST backend.acherryontop.com/apiv2/<subpath>
|
||||
// -> PHP JSON response relayed back verbatim.
|
||||
//
|
||||
// It is a constrained passthrough: only the known product/category/PO write
|
||||
// surfaces are allowed, so a valid token can't be used to proxy arbitrary
|
||||
// backend paths.
|
||||
|
||||
import express from 'express';
|
||||
import { apiv2Request, checkSession, isHtmlResponse } from '../utils/acotBackendSession.js';
|
||||
import { logger } from '../../shared/logging/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Allowed `/apiv2/<...>` path prefixes. Covers product edits/imports, category
|
||||
// creation, and purchase-order creation — the surfaces the skills need.
|
||||
const ALLOWED_PREFIXES = ['product/', 'prod_cat/', 'po/'];
|
||||
|
||||
function isAllowed(subpath) {
|
||||
return ALLOWED_PREFIXES.some((prefix) => subpath === prefix.slice(0, -1) || subpath.startsWith(prefix));
|
||||
}
|
||||
|
||||
// Health/credential check: forces a fresh login and reports the result so an
|
||||
// operator can validate ACOT_BACKEND_USERID/PIN without sending a real payload.
|
||||
router.get('/_health', async (_req, res) => {
|
||||
try {
|
||||
const status = await checkSession();
|
||||
res.json({ ok: status.authenticated, ...status });
|
||||
} catch (err) {
|
||||
logger.error({ err }, 'apiv2-bridge health check failed');
|
||||
res.status(502).json({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Reconstruct an outbound body + content-type from the already-parsed Express
|
||||
// request. The global json/urlencoded parsers have consumed the raw stream, so
|
||||
// we re-serialize from req.body to match what the caller sent.
|
||||
function serializeBody(req) {
|
||||
const incoming = (req.headers['content-type'] || '').toLowerCase();
|
||||
|
||||
if (incoming.includes('application/json')) {
|
||||
return { contentType: 'application/json; charset=utf-8', body: JSON.stringify(req.body ?? {}) };
|
||||
}
|
||||
|
||||
if (incoming.includes('application/x-www-form-urlencoded')) {
|
||||
// The real payloads use flat string fields (e.g. `products`/`items` are
|
||||
// already JSON strings), which round-trip cleanly through URLSearchParams.
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(req.body ?? {})) {
|
||||
params.append(key, typeof value === 'string' ? value : JSON.stringify(value));
|
||||
}
|
||||
return { contentType: 'application/x-www-form-urlencoded; charset=utf-8', body: params.toString() };
|
||||
}
|
||||
|
||||
// No/own content-type: forward as-is (string or undefined).
|
||||
return { contentType: incoming || undefined, body: req.body };
|
||||
}
|
||||
|
||||
// Single passthrough handler for every allowed apiv2 subpath. The wildcard
|
||||
// captures everything after the mount point (e.g. `product/setup_new`).
|
||||
router.all('/*', async (req, res, next) => {
|
||||
// `req.params[0]` is the wildcard capture; strip any leading slash.
|
||||
const subpath = String(req.params[0] || '').replace(/^\/+/, '');
|
||||
|
||||
if (!subpath || subpath.startsWith('_')) return next();
|
||||
|
||||
if (!isAllowed(subpath)) {
|
||||
return res.status(403).json({
|
||||
error: 'Path not allowed through the apiv2 bridge',
|
||||
allowed: ALLOWED_PREFIXES,
|
||||
});
|
||||
}
|
||||
|
||||
// Preserve the original query string (e.g. `?use_test_data_source=1`).
|
||||
const qIndex = req.originalUrl.indexOf('?');
|
||||
const query = qIndex === -1 ? '' : req.originalUrl.slice(qIndex + 1);
|
||||
|
||||
const { contentType, body } = serializeBody(req);
|
||||
|
||||
try {
|
||||
const upstream = await apiv2Request({
|
||||
method: req.method,
|
||||
path: `/apiv2/${subpath}`,
|
||||
query,
|
||||
contentType,
|
||||
body,
|
||||
});
|
||||
|
||||
// If PHP still served the HTML login page (re-login inside apiv2Request
|
||||
// couldn't recover the session), don't relay it as a misleading 200 — the
|
||||
// caller would have to sniff HTML to notice. Surface it as a clear 502.
|
||||
if (isHtmlResponse(upstream.data)) {
|
||||
logger.error({ subpath, status: upstream.status }, 'apiv2-bridge got HTML (PHP session unavailable)');
|
||||
return res.status(502).json({
|
||||
error: 'Backend session unavailable',
|
||||
message: 'The PHP backend returned its login page — the service account session could not be established. Check /api/apiv2-bridge/_health.',
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ user: req.user?.username, method: req.method, subpath, status: upstream.status },
|
||||
'apiv2-bridge relayed request'
|
||||
);
|
||||
|
||||
res.status(upstream.status).type(upstream.contentType).send(upstream.data);
|
||||
} catch (err) {
|
||||
logger.error({ err, subpath }, 'apiv2-bridge relay failed');
|
||||
res.status(502).json({ error: 'Upstream backend request failed', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -37,6 +37,7 @@ import productEditorAuditLogRouter from './routes/product-editor-audit-log.js';
|
||||
import newsletterRouter from './routes/newsletter.js';
|
||||
import linesAggregateRouter from './routes/linesAggregate.js';
|
||||
import repeatOrdersRouter from './routes/repeat-orders.js';
|
||||
import apiv2BridgeRouter from './routes/apiv2-bridge.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -142,6 +143,10 @@ async function startServer() {
|
||||
app.use('/api/newsletter', newsletterRouter);
|
||||
app.use('/api/lines-aggregate', linesAggregateRouter);
|
||||
app.use('/api/repeat-orders', repeatOrdersRouter);
|
||||
// Side-service: lets external apps (import / product-edit skills) post to the
|
||||
// legacy PHP /apiv2 API without a browser cookie. Isolated, additive router;
|
||||
// inherits the same user-JWT auth applied to /api above. See apiv2-bridge.js.
|
||||
app.use('/api/apiv2-bridge', apiv2BridgeRouter);
|
||||
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
// Server-side session manager for the legacy PHP backend (backend.acherryontop.com).
|
||||
//
|
||||
// WHY THIS EXISTS
|
||||
// ---------------
|
||||
// The PHP `/apiv2/*` write endpoints (product edit, image_changes, taxonomy
|
||||
// set, setup_new, prod_cat/new, po/new, po/add_products) authenticate via a
|
||||
// PHP session cookie. In the browser the frontend gets that for free with
|
||||
// `credentials: 'include'` because the user is logged into
|
||||
// backend.acherryontop.com in the same browser — that flow is unchanged and
|
||||
// this module does NOT touch it.
|
||||
//
|
||||
// This module backs a separate SIDE-SERVICE (see routes/apiv2-bridge.js) that
|
||||
// lets EXTERNAL apps (the product-import skill, a future product-edit skill)
|
||||
// post to the PHP API without a browser cookie. The inventory-server holds the
|
||||
// PHP session itself: it logs in once with a SERVICE account, caches every
|
||||
// cookie the backend hands back, and replays them on outbound `/apiv2/*`
|
||||
// requests. Callers authenticate to the inventory-server with the normal user
|
||||
// JWT; this PHP-session layer is invisible to them.
|
||||
//
|
||||
// LOGIN FLOW (discovered from the live login page)
|
||||
// ------------------------------------------------
|
||||
// 1. GET /login -> sets affinity/session cookies + embeds a
|
||||
// hidden `anti_csrf` token in the form HTML.
|
||||
// 2. POST /login/login -> { anti_csrf, userid, pin, after_login:'',
|
||||
// Submit:'Submit' } with the cookies from (1).
|
||||
// On success the backend rotates/sets the
|
||||
// authenticated session cookie.
|
||||
//
|
||||
// EXPIRY DETECTION
|
||||
// ----------------
|
||||
// Unauthenticated `/apiv2/*` calls return HTTP 200 with an HTML body (the login
|
||||
// page), NOT a 401. So we sniff the response body the same way the frontend
|
||||
// does (isHtmlResponse) and treat an HTML payload as "session dead" -> re-login
|
||||
// once and retry.
|
||||
|
||||
import axios from 'axios';
|
||||
import { logger } from '../../shared/logging/logger.js';
|
||||
|
||||
const BASE_URL = (process.env.ACOT_BACKEND_URL || 'https://backend.acherryontop.com').replace(/\/$/, '');
|
||||
const USERID = process.env.ACOT_BACKEND_USERID;
|
||||
const PIN = process.env.ACOT_BACKEND_PIN;
|
||||
const USER_AGENT = 'inventory-server/apiv2-bridge';
|
||||
|
||||
// Single host => a simple name->value map is a sufficient cookie jar. We do NOT
|
||||
// hardcode any cookie name: the backend sets a load-balancer affinity cookie
|
||||
// (`S=...`) AND the real session cookie, and both must be replayed for the
|
||||
// session to stick. Capturing every Set-Cookie generically handles both.
|
||||
let cookieJar = new Map();
|
||||
// Mutex: collapse concurrent login attempts into one in-flight promise so a
|
||||
// burst of bridged requests doesn't trigger N parallel logins.
|
||||
let loginInFlight = null;
|
||||
|
||||
function cookieHeader() {
|
||||
return Array.from(cookieJar.entries())
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function storeSetCookies(setCookieHeaders) {
|
||||
if (!setCookieHeaders) return;
|
||||
const list = Array.isArray(setCookieHeaders) ? setCookieHeaders : [setCookieHeaders];
|
||||
for (const raw of list) {
|
||||
const first = String(raw).split(';')[0];
|
||||
const eq = first.indexOf('=');
|
||||
if (eq === -1) continue;
|
||||
const name = first.slice(0, eq).trim();
|
||||
const value = first.slice(eq + 1).trim();
|
||||
if (!name) continue;
|
||||
// A `name=deleted`/empty value means the backend is clearing the cookie.
|
||||
if (value === '' || value.toLowerCase() === 'deleted') {
|
||||
cookieJar.delete(name);
|
||||
} else {
|
||||
cookieJar.set(name, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isHtmlResponse(payload) {
|
||||
if (typeof payload !== 'string') return false;
|
||||
const trimmed = payload.trim().toLowerCase();
|
||||
return trimmed.startsWith('<!doctype html') || trimmed.startsWith('<html');
|
||||
}
|
||||
|
||||
// Find the `name=` of the first <input> whose `type=` matches, tolerant of
|
||||
// attribute order and quote style.
|
||||
function inputNameByType(html, type) {
|
||||
const tags = String(html).match(/<input\b[^>]*>/gi) || [];
|
||||
const typeRe = new RegExp(`type=['"]${type}['"]`, 'i');
|
||||
for (const tag of tags) {
|
||||
if (typeRe.test(tag)) {
|
||||
const nm = /name=['"]([^'"]+)['"]/i.exec(tag);
|
||||
if (nm) return nm[1];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasPasswordField(html) {
|
||||
return /<input\b[^>]*type=['"]password['"]/i.test(String(html));
|
||||
}
|
||||
|
||||
async function performLogin() {
|
||||
if (!USERID || !PIN) {
|
||||
throw new Error(
|
||||
'ACOT backend service credentials are not configured (set ACOT_BACKEND_USERID and ACOT_BACKEND_PIN)'
|
||||
);
|
||||
}
|
||||
|
||||
// Fresh jar for a clean login.
|
||||
cookieJar = new Map();
|
||||
|
||||
// Step 1: GET the login page for cookies + the anti-CSRF token. The login is
|
||||
// IP-based and serves DIFFERENT forms per network (office: userid/pin; outside:
|
||||
// username/password), so we parse the served form rather than hardcoding field
|
||||
// names — the credential pair maps to whatever identifier/secret fields appear.
|
||||
const getRes = await axios.get(`${BASE_URL}/login`, {
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true,
|
||||
headers: { 'User-Agent': USER_AGENT },
|
||||
responseType: 'text',
|
||||
transformResponse: (d) => d,
|
||||
});
|
||||
storeSetCookies(getRes.headers['set-cookie']);
|
||||
const page = getRes.data || '';
|
||||
|
||||
const csrfMatch = /name=['"]anti_csrf['"][^>]*value=['"]([^'"]+)['"]/i.exec(page);
|
||||
if (!csrfMatch) {
|
||||
throw new Error('Could not locate anti_csrf token on the backend login page');
|
||||
}
|
||||
const antiCsrf = csrfMatch[1];
|
||||
|
||||
const actionMatch = /<form\b[^>]*action=['"]([^'"]+)['"]/i.exec(page);
|
||||
const action = actionMatch ? actionMatch[1] : '/login/login';
|
||||
const loginUrl = action.startsWith('http') ? action : `${BASE_URL}${action}`;
|
||||
|
||||
// Identifier field is the first text/email input; secret is the password input.
|
||||
const idField = inputNameByType(page, 'text') || inputNameByType(page, 'email') || 'username';
|
||||
const secretField = inputNameByType(page, 'password') || 'password';
|
||||
|
||||
// Step 2: POST credentials with the cookies from step 1.
|
||||
const form = new URLSearchParams();
|
||||
form.append('anti_csrf', antiCsrf);
|
||||
form.append(idField, USERID);
|
||||
form.append(secretField, PIN);
|
||||
form.append('after_login', '');
|
||||
form.append('Submit', 'Submit');
|
||||
|
||||
const postRes = await axios.post(loginUrl, form.toString(), {
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true,
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
'User-Agent': USER_AGENT,
|
||||
Cookie: cookieHeader(),
|
||||
},
|
||||
responseType: 'text',
|
||||
transformResponse: (d) => d,
|
||||
});
|
||||
storeSetCookies(postRes.headers['set-cookie']);
|
||||
|
||||
// Success heuristic: a failed login re-renders the login form (a page that
|
||||
// still has a password field). A redirect, or any non-form response, is
|
||||
// treated as success here — but the authoritative check is the /apiv2 probe in
|
||||
// checkSession(), since the form may differ across deployments.
|
||||
const redirected = postRes.status >= 300 && postRes.status < 400;
|
||||
if (!redirected && hasPasswordField(postRes.data)) {
|
||||
throw new Error('Backend login failed — credentials rejected (login form re-served)');
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ status: postRes.status, idField, secretField, cookies: cookieJar.size },
|
||||
'acot backend session established'
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureSession() {
|
||||
if (cookieJar.size > 0) return;
|
||||
if (!loginInFlight) {
|
||||
loginInFlight = performLogin().finally(() => {
|
||||
loginInFlight = null;
|
||||
});
|
||||
}
|
||||
await loginInFlight;
|
||||
}
|
||||
|
||||
async function forceRelogin() {
|
||||
cookieJar = new Map();
|
||||
if (!loginInFlight) {
|
||||
loginInFlight = performLogin().finally(() => {
|
||||
loginInFlight = null;
|
||||
});
|
||||
}
|
||||
await loginInFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an authenticated request to a PHP `/apiv2/...` path, transparently
|
||||
* handling login and a single re-login-on-expiry retry.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.method HTTP method (GET/POST/...).
|
||||
* @param {string} opts.path Path beginning with `/apiv2/...`.
|
||||
* @param {string} [opts.query] Raw query string (without leading `?`).
|
||||
* @param {string} [opts.contentType] Outbound Content-Type header.
|
||||
* @param {string|Buffer} [opts.body] Serialized request body.
|
||||
* @returns {Promise<{status:number, contentType:string, data:string}>}
|
||||
*/
|
||||
export async function apiv2Request({ method, path, query, contentType, body }) {
|
||||
await ensureSession();
|
||||
|
||||
const url = `${BASE_URL}${path}${query ? `?${query}` : ''}`;
|
||||
const send = () =>
|
||||
axios.request({
|
||||
url,
|
||||
method,
|
||||
data: body,
|
||||
maxRedirects: 0,
|
||||
validateStatus: () => true,
|
||||
responseType: 'text',
|
||||
transformResponse: (d) => d,
|
||||
headers: {
|
||||
'User-Agent': USER_AGENT,
|
||||
Cookie: cookieHeader(),
|
||||
...(contentType ? { 'Content-Type': contentType } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
let res = await send();
|
||||
|
||||
// Session expired / not actually authenticated -> the backend serves the
|
||||
// HTML login page. Re-login once and retry.
|
||||
if (isHtmlResponse(res.data)) {
|
||||
logger.warn({ path }, 'acot apiv2 returned HTML (session expired) — re-logging in');
|
||||
await forceRelogin();
|
||||
res = await send();
|
||||
}
|
||||
|
||||
return {
|
||||
status: res.status,
|
||||
contentType: res.headers['content-type'] || 'application/json',
|
||||
data: typeof res.data === 'string' ? res.data : JSON.stringify(res.data),
|
||||
};
|
||||
}
|
||||
|
||||
/** Force a fresh login, then PROVE the session by probing `/apiv2`. A login
|
||||
* that merely "doesn't show a login form" is not proof — the only reliable
|
||||
* signal is that `/apiv2` answers with JSON rather than the HTML login page.
|
||||
* The probe is a no-op: an empty product edit (`products=[]`) edits nothing.
|
||||
* Reports cookie NAMES only (never values). Used by the bridge `_health` route. */
|
||||
export async function checkSession() {
|
||||
let loginError = null;
|
||||
try {
|
||||
await forceRelogin();
|
||||
} catch (err) {
|
||||
loginError = err.message;
|
||||
}
|
||||
|
||||
let probe;
|
||||
try {
|
||||
probe = await apiv2Request({
|
||||
method: 'POST',
|
||||
path: '/apiv2/product/edit',
|
||||
contentType: 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
body: 'products=%5B%5D', // products=[]
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
authenticated: false,
|
||||
error: `apiv2 probe failed: ${err.message}`,
|
||||
loginError,
|
||||
cookieNames: Array.from(cookieJar.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
const servedLoginPage = isHtmlResponse(probe.data);
|
||||
return {
|
||||
authenticated: !servedLoginPage,
|
||||
probeStatus: probe.status,
|
||||
cookieNames: Array.from(cookieJar.keys()),
|
||||
...(loginError ? { loginError } : {}),
|
||||
sample: servedLoginPage
|
||||
? '(received HTML login page — PHP rejected the session)'
|
||||
: String(probe.data).slice(0, 160),
|
||||
};
|
||||
}
|
||||
@@ -37,7 +37,7 @@ const sublinesCache = new Map<string, Promise<LineOption[]>>();
|
||||
function fetchLinesCached(companyId: string, signal?: AbortSignal): Promise<LineOption[]> {
|
||||
const cached = linesCache.get(companyId);
|
||||
if (cached) return cached;
|
||||
const p = axios
|
||||
const p = apiClient
|
||||
.get(`/api/import/product-lines/${companyId}`, { signal })
|
||||
.then((res) => res.data as LineOption[])
|
||||
.catch(() => {
|
||||
@@ -51,7 +51,7 @@ function fetchLinesCached(companyId: string, signal?: AbortSignal): Promise<Line
|
||||
function fetchSublinesCached(lineId: string, signal?: AbortSignal): Promise<LineOption[]> {
|
||||
const cached = sublinesCache.get(lineId);
|
||||
if (cached) return cached;
|
||||
const p = axios
|
||||
const p = apiClient
|
||||
.get(`/api/import/sublines/${lineId}`, { signal })
|
||||
.then((res) => res.data as LineOption[])
|
||||
.catch(() => {
|
||||
@@ -275,7 +275,7 @@ export function ProductEditForm({
|
||||
originalImagesRef.current = initialImages;
|
||||
} else {
|
||||
setIsLoadingImages(true);
|
||||
axios
|
||||
apiClient
|
||||
.get(`/api/import/product-images/${product.pid}`, { signal })
|
||||
.then((res) => {
|
||||
setProductImages(res.data);
|
||||
@@ -285,7 +285,7 @@ export function ProductEditForm({
|
||||
.finally(() => setIsLoadingImages(false));
|
||||
}
|
||||
|
||||
axios
|
||||
apiClient
|
||||
.get(`/api/import/product-categories/${product.pid}`, { signal })
|
||||
.then((res) => {
|
||||
const cats: string[] = [];
|
||||
|
||||
+19
-9
@@ -607,14 +607,21 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
headerValues,
|
||||
onContinue,
|
||||
onBack,
|
||||
initialGlobalSelections
|
||||
initialGlobalSelections,
|
||||
initialColumns
|
||||
}: MatchColumnsProps<T>): JSX.Element => {
|
||||
const { fields, autoMapHeaders, autoMapSelectValues, autoMapDistance, translations } = useRsi<T>()
|
||||
const queryClient = useQueryClient()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
|
||||
|
||||
const [columns, setColumns] = useState<Columns<T>>(() => {
|
||||
// Restoring from a previous visit (back-nav): reuse the prior mappings verbatim
|
||||
// instead of re-deriving empty columns + auto-mapping.
|
||||
if (initialColumns && initialColumns.length > 0) {
|
||||
return initialColumns;
|
||||
}
|
||||
|
||||
// Helper function to check if a column is completely empty
|
||||
const isColumnEmpty = (columnIndex: number) => {
|
||||
return data.every(row => {
|
||||
@@ -622,14 +629,14 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
return value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Do not remove spread, it indexes empty array elements, otherwise map() skips over them
|
||||
const allColumns = ([...headerValues] as string[]).map((value, index) => ({
|
||||
type: ColumnType.empty as ColumnType.empty,
|
||||
index,
|
||||
header: value ?? ""
|
||||
const allColumns = ([...headerValues] as string[]).map((value, index) => ({
|
||||
type: ColumnType.empty as ColumnType.empty,
|
||||
index,
|
||||
header: value ?? ""
|
||||
}));
|
||||
|
||||
|
||||
// Filter out completely empty columns
|
||||
return allColumns.filter(col => !isColumnEmpty(col.index)) as Columns<T>;
|
||||
})
|
||||
@@ -637,7 +644,10 @@ const MatchColumnsStepComponent = <T extends string>({
|
||||
const [showAllColumns, setShowAllColumns] = useState(true)
|
||||
const [expandedValues, setExpandedValues] = useState<number[]>([])
|
||||
const [userCollapsedColumns, setUserCollapsedColumns] = useState<number[]>([])
|
||||
const hasAutoMappedRef = useRef(false)
|
||||
// When restoring prior columns, suppress the initial header auto-map so it doesn't
|
||||
// overwrite the restored mappings (the ref guards within a mount; seeding it true
|
||||
// covers the fresh-mount-on-back-nav case).
|
||||
const hasAutoMappedRef = useRef(!!(initialColumns && initialColumns.length > 0))
|
||||
|
||||
// Toggle with immediate visual feedback
|
||||
const toggleValueMappingOptimized = useCallback((columnIndex: number) => {
|
||||
|
||||
@@ -6,6 +6,12 @@ export type MatchColumnsProps<T extends string> = {
|
||||
onContinue: (data: any[], rawData: RawData[], columns: Columns<T>, globalSelections?: GlobalSelections, useNewValidation?: boolean) => void
|
||||
onBack?: () => void
|
||||
initialGlobalSelections?: GlobalSelections
|
||||
/**
|
||||
* Previously-matched columns to restore when navigating back to this step.
|
||||
* When provided, columns seed from these instead of re-deriving + auto-mapping,
|
||||
* so the user's mappings/value-mappings/ignored/AI-supplemental state survive back-nav.
|
||||
*/
|
||||
initialColumns?: Columns<T>
|
||||
}
|
||||
|
||||
export type GlobalSelections = {
|
||||
|
||||
@@ -8,13 +8,14 @@ import { mapWorkbook } from "../utils/mapWorkbook"
|
||||
import { ValidationStep } from "./ValidationStep"
|
||||
import { ImageUploadStep } from "./ImageUploadStep/ImageUploadStep"
|
||||
import { MatchColumnsStep } from "./MatchColumnsStep/MatchColumnsStep"
|
||||
import type { GlobalSelections } from "./MatchColumnsStep/types"
|
||||
import type { GlobalSelections, Columns } from "./MatchColumnsStep/types"
|
||||
import { exceedsMaxRecords } from "../utils/exceedsMaxRecords"
|
||||
import { useRsi } from "../hooks/useRsi"
|
||||
import type { RawData, Data } from "../types"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { addErrorsAndRunHooks } from "./ValidationStep/utils/dataMutations"
|
||||
import { computeMappingSignature, type MappingSignature } from "./ValidationStep/utils/mappingSignature"
|
||||
import { useValidationStore } from "./ValidationStep/store/validationStore"
|
||||
import { useImportSession } from "@/contexts/ImportSessionContext"
|
||||
import type { ImportSession } from "@/types/importSession"
|
||||
@@ -52,6 +53,7 @@ export type StepState =
|
||||
data: any[]
|
||||
globalSelections?: GlobalSelections
|
||||
isFromScratch?: boolean
|
||||
mappingSignature?: MappingSignature
|
||||
}
|
||||
| {
|
||||
type: StepType.validateDataNew
|
||||
@@ -59,6 +61,7 @@ export type StepState =
|
||||
file?: File
|
||||
globalSelections?: GlobalSelections
|
||||
isFromScratch?: boolean
|
||||
mappingSignature?: MappingSignature
|
||||
}
|
||||
| {
|
||||
type: StepType.imageUpload
|
||||
@@ -129,6 +132,11 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
: undefined
|
||||
)
|
||||
|
||||
// Keep the user's matched columns so navigating back to Match Columns restores them
|
||||
// (UploadFlow never unmounts during step nav, so this survives back-nav — same pattern
|
||||
// as persistedGlobalSelections above).
|
||||
const [persistedColumns, setPersistedColumns] = useState<Columns<string> | undefined>(undefined)
|
||||
|
||||
// Import session context for session restoration
|
||||
const { loadSession, setGlobalSelections: setSessionGlobalSelections } = useImportSession()
|
||||
|
||||
@@ -254,30 +262,36 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
data={state.data}
|
||||
headerValues={state.headerValues}
|
||||
initialGlobalSelections={persistedGlobalSelections}
|
||||
initialColumns={persistedColumns}
|
||||
onContinue={async (values, rawData, columns, globalSelections, useNewValidation) => {
|
||||
try {
|
||||
const data = await matchColumnsStepHook(values, rawData, columns)
|
||||
const dataWithMeta = await addErrorsAndRunHooks(data, fields, rowHook, tableHook, undefined, { costIsTotalCost: globalSelections?.costIsTotalCost })
|
||||
|
||||
// Apply global selections to each row of data if they exist
|
||||
const dataWithGlobalSelections = globalSelections
|
||||
? dataWithMeta.map((row: Data<string> & { __index?: string }) => {
|
||||
const newRow = { ...row } as any;
|
||||
if (globalSelections.supplier) newRow.supplier = globalSelections.supplier;
|
||||
if (globalSelections.company) newRow.company = globalSelections.company;
|
||||
if (globalSelections.line) newRow.line = globalSelections.line;
|
||||
if (globalSelections.subline) newRow.subline = globalSelections.subline;
|
||||
return newRow;
|
||||
})
|
||||
: dataWithMeta;
|
||||
// Apply global selections to each row of data if they exist, and stamp a
|
||||
// stable positional id (__sourceRow) so the Validation store can align these
|
||||
// freshly-mapped rows with previously-edited rows on back-nav re-entry.
|
||||
const dataWithGlobalSelections = dataWithMeta.map(
|
||||
(row: Data<string> & { __index?: string }, rowIndex: number) => {
|
||||
const newRow = { ...row } as any;
|
||||
newRow.__sourceRow = rowIndex;
|
||||
if (globalSelections?.supplier) newRow.supplier = globalSelections.supplier;
|
||||
if (globalSelections?.company) newRow.company = globalSelections.company;
|
||||
if (globalSelections?.line) newRow.line = globalSelections.line;
|
||||
if (globalSelections?.subline) newRow.subline = globalSelections.subline;
|
||||
return newRow;
|
||||
},
|
||||
);
|
||||
|
||||
setPersistedGlobalSelections(globalSelections)
|
||||
setPersistedColumns(columns)
|
||||
|
||||
// Route to new or old validation step based on user choice
|
||||
onNext({
|
||||
type: useNewValidation ? StepType.validateDataNew : StepType.validateData,
|
||||
data: dataWithGlobalSelections,
|
||||
globalSelections,
|
||||
mappingSignature: computeMappingSignature(columns, globalSelections),
|
||||
})
|
||||
} catch (e) {
|
||||
errorToast((e as Error).message)
|
||||
@@ -293,6 +307,7 @@ export const UploadFlow = ({ state, onNext, onBack }: Props) => {
|
||||
<ValidationStep
|
||||
initialData={state.data}
|
||||
file={uploadedFile || new File([], "empty.xlsx")}
|
||||
mappingSignature={state.mappingSignature}
|
||||
onBack={() => {
|
||||
// If we started from scratch, we need to go back to the upload step
|
||||
if (state.isFromScratch) {
|
||||
|
||||
+95
-22
@@ -924,7 +924,7 @@ const CellWrapper = memo(({
|
||||
|
||||
{/* Copy-down button - appears on hover, positioned to avoid error icons */}
|
||||
{showCopyDownButton && (
|
||||
<TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1707,6 +1707,10 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
|
||||
|
||||
const isMsrp = fieldKey === 'msrp';
|
||||
|
||||
// Cost Each offers a per-row "divide by Min Qty" action that operates on the current
|
||||
// row selection only — subscribe to the selection count to enable/disable it.
|
||||
const selectedCount = useValidationStore((state) => (isMsrp ? 0 : state.selectedRows.size));
|
||||
|
||||
// Determine the source field
|
||||
const sourceField = isMsrp ? 'cost_each' : 'msrp';
|
||||
const tooltipText = isMsrp
|
||||
@@ -1817,6 +1821,40 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
|
||||
}
|
||||
}, [fieldKey, sourceField, label]);
|
||||
|
||||
/**
|
||||
* Divide cost_each by Min Qty (qty_per_unit) for the currently-SELECTED rows only.
|
||||
* For cases where only some rows list a pack total but are sold individually.
|
||||
* Mirrors the global "divide by min qty" math in dataMutations.ts.
|
||||
*/
|
||||
const handleDivideSelectedByMinQty = useCallback(() => {
|
||||
const { selectedRows } = useValidationStore.getState();
|
||||
const updatedIndices: number[] = [];
|
||||
|
||||
useValidationStore.setState((draft) => {
|
||||
draft.rows.forEach((row, index) => {
|
||||
if (!selectedRows.has(row.__index)) return;
|
||||
const cost = parseFloat(String(row.cost_each ?? ''));
|
||||
const qty = parseInt(String(row.qty_per_unit ?? ''), 10);
|
||||
if (!isNaN(cost) && qty > 0) {
|
||||
draft.rows[index].cost_each = (cost / qty).toFixed(2);
|
||||
updatedIndices.push(index);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (updatedIndices.length > 0) {
|
||||
const { clearFieldError } = useValidationStore.getState();
|
||||
updatedIndices.forEach((rowIndex) => {
|
||||
clearFieldError(rowIndex, 'cost_each');
|
||||
});
|
||||
toast.success(`Divided ${updatedIndices.length} ${label} value${updatedIndices.length === 1 ? '' : 's'} by Min Qty`);
|
||||
} else {
|
||||
toast.error('No selected rows had both a cost and a Min Qty > 0');
|
||||
}
|
||||
|
||||
setIsPopoverOpen(false);
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 min-w-0 w-full group relative"
|
||||
@@ -1831,7 +1869,7 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
|
||||
)}
|
||||
{/* Button group: pin button always visible when pinned, action buttons only on hover */}
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
{(isHovered || isPopoverOpen) && hasFillableCells && (
|
||||
{(isHovered || isPopoverOpen) && (hasFillableCells || (!isMsrp && selectedCount > 0)) && (
|
||||
isMsrp ? (
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipProvider>
|
||||
@@ -1905,29 +1943,64 @@ const PriceColumnHeader = memo(({ fieldKey, label, isRequired, pinButton }: Pric
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className={cn(
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background p-1 shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Calculator className="h-3 w-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>Calculate Cost Each</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<PopoverContent className="w-60 p-3" align="end">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">
|
||||
Calculate Cost Each
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="w-full h-7 text-xs"
|
||||
disabled={!hasFillableCells}
|
||||
onClick={() => {
|
||||
handleCalculateCostEach();
|
||||
setIsPopoverOpen(false);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-0.5',
|
||||
'rounded border border-input bg-background p-1 shadow-sm',
|
||||
'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
<Calculator className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
<p>{tooltipText}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
Fill from MSRP ÷ 2
|
||||
</Button>
|
||||
<div className="border-t pt-3 space-y-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs"
|
||||
disabled={selectedCount === 0}
|
||||
onClick={handleDivideSelectedByMinQty}
|
||||
>
|
||||
Divide selected by Min Qty
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedCount > 0
|
||||
? `Divides cost ÷ Min Qty for ${selectedCount} selected row${selectedCount === 1 ? '' : 's'}.`
|
||||
: 'Select rows first to divide their cost by Min Qty.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
)}
|
||||
{pinButton}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 4. Renders the ValidationContainer once initialized
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useDeferredValue, useMemo } from 'react';
|
||||
import { useEffect, useRef, useDeferredValue } from 'react';
|
||||
import { apiFetch } from '@/utils/api';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useValidationStore } from './store/validationStore';
|
||||
@@ -22,39 +22,10 @@ import { useProductLines } from './hooks/useProductLines';
|
||||
import { useAutoInlineAiValidation } from './hooks/useAutoInlineAiValidation';
|
||||
import { BASE_IMPORT_FIELDS } from '../../config';
|
||||
import config from '@/config';
|
||||
import type { ValidationStepProps } from './store/types';
|
||||
import { diffMappingSignatures } from './utils/mappingSignature';
|
||||
import type { ValidationStepProps, RowData } from './store/types';
|
||||
import type { Field, SelectOption } from '../../types';
|
||||
|
||||
/**
|
||||
* Create a fingerprint of the data to detect changes.
|
||||
* This is used to determine if we need to re-initialize the store
|
||||
* when navigating back to this step with potentially modified data.
|
||||
*/
|
||||
const createDataFingerprint = (data: Record<string, unknown>[]): string => {
|
||||
// Sample key fields that are likely to change when user modifies data in previous steps
|
||||
const keyFields = ['supplier', 'company', 'line', 'subline', 'name', 'upc', 'item_number'];
|
||||
|
||||
// Create a simple hash from first few rows + last row + count
|
||||
const sampleSize = Math.min(3, data.length);
|
||||
const samples: string[] = [];
|
||||
|
||||
// First few rows
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const row = data[i];
|
||||
const values = keyFields.map(k => String(row[k] ?? '')).join('|');
|
||||
samples.push(values);
|
||||
}
|
||||
|
||||
// Last row (if different from samples)
|
||||
if (data.length > sampleSize) {
|
||||
const lastRow = data[data.length - 1];
|
||||
const values = keyFields.map(k => String(lastRow[k] ?? '')).join('|');
|
||||
samples.push(values);
|
||||
}
|
||||
|
||||
return `${data.length}:${samples.join(';;')}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch field options from the API
|
||||
*/
|
||||
@@ -122,6 +93,7 @@ export const ValidationStep = ({
|
||||
onBack,
|
||||
onNext,
|
||||
isFromScratch,
|
||||
mappingSignature,
|
||||
}: ValidationStepProps) => {
|
||||
const initPhase = useInitPhase();
|
||||
const isReady = useIsReady();
|
||||
@@ -136,7 +108,6 @@ export const ValidationStep = ({
|
||||
const templatesLoadedRef = useRef(false);
|
||||
const upcValidationStartedRef = useRef(false);
|
||||
const fieldValidationStartedRef = useRef(false);
|
||||
const lastDataFingerprintRef = useRef<string | null>(null);
|
||||
|
||||
// Debug logging
|
||||
console.log('[ValidationStep] Render - initPhase:', initPhase, 'isReady:', isReady);
|
||||
@@ -146,6 +117,8 @@ export const ValidationStep = ({
|
||||
const setFields = useValidationStore((state) => state.setFields);
|
||||
const setFieldOptionsLoaded = useValidationStore((state) => state.setFieldOptionsLoaded);
|
||||
const setInitPhase = useValidationStore((state) => state.setInitPhase);
|
||||
const setMappingSignature = useValidationStore((state) => state.setMappingSignature);
|
||||
const reconcileMappedData = useValidationStore((state) => state.reconcileMappedData);
|
||||
|
||||
// Initialization hooks
|
||||
const { loadTemplates } = useTemplateManagement();
|
||||
@@ -164,59 +137,78 @@ export const ValidationStep = ({
|
||||
retry: 2,
|
||||
});
|
||||
|
||||
// Create a fingerprint of the incoming data to detect changes
|
||||
const dataFingerprint = useMemo(() => createDataFingerprint(initialData), [initialData]);
|
||||
|
||||
// Initialize store with data
|
||||
// Initialize / reconcile store with data.
|
||||
//
|
||||
// Three cases, decided once per mount (refs are fresh because this component unmounts
|
||||
// whenever another step is shown):
|
||||
// A. Fresh store (not yet ready) -> full initialize() + phase chain
|
||||
// B. Ready store, mapping unchanged -> skip everything (preserve all edits + UPC results)
|
||||
// C. Ready store, mapping changed -> merge changed fields only, then re-run
|
||||
// field validation (and UPC only if the
|
||||
// supplier / UPC column mapping changed)
|
||||
useEffect(() => {
|
||||
console.log('[ValidationStep] Init effect running, initStartedRef:', initStartedRef.current, 'initPhase:', initPhase);
|
||||
console.log('[ValidationStep] Data fingerprint:', dataFingerprint, 'Last fingerprint:', lastDataFingerprintRef.current);
|
||||
|
||||
// Check if data has changed since last initialization
|
||||
const dataHasChanged = lastDataFingerprintRef.current !== null && lastDataFingerprintRef.current !== dataFingerprint;
|
||||
|
||||
if (dataHasChanged) {
|
||||
console.log('[ValidationStep] Data has changed - forcing re-initialization');
|
||||
// Reset all refs to allow re-initialization
|
||||
initStartedRef.current = false;
|
||||
templatesLoadedRef.current = false;
|
||||
upcValidationStartedRef.current = false;
|
||||
fieldValidationStartedRef.current = false;
|
||||
}
|
||||
|
||||
// Skip if already initialized (check both ref AND store state)
|
||||
// The ref prevents double-init within the same mount cycle
|
||||
// Checking initPhase handles StrictMode remounts where store was initialized but ref persisted
|
||||
if (initStartedRef.current && initPhase !== 'idle') {
|
||||
console.log('[ValidationStep] Skipping init - already initialized');
|
||||
if (initStartedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// IMPORTANT: Skip initialization if we're returning to an already-ready store
|
||||
// with the SAME data. This happens when navigating back from ImageUploadStep.
|
||||
// We compare fingerprints to detect if the data has actually changed.
|
||||
if (initPhase === 'ready' && !dataHasChanged && lastDataFingerprintRef.current === dataFingerprint) {
|
||||
console.log('[ValidationStep] Skipping init - returning to already-ready store with same data');
|
||||
const store = useValidationStore.getState();
|
||||
const storeReady = store.initPhase === 'ready';
|
||||
const incomingSig = mappingSignature ?? null;
|
||||
|
||||
// --- Case A: fresh store -> full initialization ---
|
||||
if (!storeReady) {
|
||||
initStartedRef.current = true;
|
||||
console.log('[ValidationStep] Case A - full init with', initialData.length, 'rows');
|
||||
|
||||
const rowData = initialData.map((row, index) => ({
|
||||
...row,
|
||||
__index: row.__index || `row-${index}-${store.rows.length}`,
|
||||
}));
|
||||
|
||||
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
||||
setMappingSignature(incomingSig);
|
||||
return;
|
||||
}
|
||||
|
||||
// Returning to an already-ready store.
|
||||
initStartedRef.current = true;
|
||||
lastDataFingerprintRef.current = dataFingerprint;
|
||||
|
||||
console.log('[ValidationStep] Starting initialization with', initialData.length, 'rows');
|
||||
// No signature (e.g. returning from ImageUpload, or restored/from-scratch flows) ->
|
||||
// nothing about the mapping could have changed; keep the store as-is.
|
||||
const diff = incomingSig
|
||||
? diffMappingSignatures(store.mappingSignature, incomingSig)
|
||||
: null;
|
||||
|
||||
// Convert initialData to RowData format
|
||||
const rowData = initialData.map((row, index) => ({
|
||||
...row,
|
||||
__index: row.__index || `row-${index}-${Date.now()}`,
|
||||
}));
|
||||
// --- Case B: nothing relevant changed -> preserve everything ---
|
||||
if (!diff || diff.equal) {
|
||||
console.log('[ValidationStep] Case B - returning with unchanged mapping, preserving edits');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start with base fields
|
||||
console.log('[ValidationStep] Calling initialize()');
|
||||
initialize(rowData, BASE_IMPORT_FIELDS as unknown as Field<string>[], file);
|
||||
console.log('[ValidationStep] initialize() called');
|
||||
}, [initialData, file, initialize, initPhase, dataFingerprint]);
|
||||
// --- Case C: mapping changed -> merge changed fields, re-validate selectively ---
|
||||
console.log('[ValidationStep] Case C - mapping changed', diff);
|
||||
reconcileMappedData(initialData as RowData[], diff.changedFieldKeys, diff.clearedFieldKeys);
|
||||
setMappingSignature(incomingSig);
|
||||
|
||||
// Re-run the relevant phase of the validation chain. The existing phase-chain effects
|
||||
// pick these transitions up (their refs are fresh on this mount):
|
||||
// - UPC inputs (supplier / upc column) changed -> re-run UPC + item-number, then fields
|
||||
// - otherwise -> only re-run field validation (no UPC network calls)
|
||||
if (diff.upcAffected) {
|
||||
store.setInitialUpcValidationDone(false);
|
||||
setInitPhase('validating-upcs');
|
||||
} else {
|
||||
setInitPhase('validating-fields');
|
||||
}
|
||||
}, [
|
||||
initialData,
|
||||
file,
|
||||
initialize,
|
||||
reconcileMappedData,
|
||||
setMappingSignature,
|
||||
setInitPhase,
|
||||
mappingSignature,
|
||||
]);
|
||||
|
||||
// Update fields when options are loaded
|
||||
// CRITICAL: Check store state (not ref) because initialize() resets the store
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { Field, SelectOption, ErrorLevel } from '../../../types';
|
||||
import type { MappingSignature } from '../utils/mappingSignature';
|
||||
|
||||
// =============================================================================
|
||||
// Core Data Types
|
||||
@@ -22,6 +23,7 @@ export interface RowData {
|
||||
__corrected?: Record<string, unknown>; // AI-corrected values
|
||||
__changes?: Record<string, boolean>; // Fields changed by AI
|
||||
__aiSupplemental?: Record<string, string>; // AI supplemental columns from MatchColumnsStep (header -> value)
|
||||
__sourceRow?: number; // Stable positional id from the raw spreadsheet (for back-nav merge)
|
||||
|
||||
// Standard fields (from config.ts)
|
||||
supplier?: string;
|
||||
@@ -395,6 +397,8 @@ export interface ValidationState {
|
||||
|
||||
// === Initialization ===
|
||||
initPhase: InitPhase;
|
||||
/** Mapping signature that produced the current rows (for back-nav merge decisions) */
|
||||
mappingSignature: MappingSignature | null;
|
||||
|
||||
// === AI Validation ===
|
||||
aiValidation: AiValidationState;
|
||||
@@ -419,6 +423,17 @@ export interface ValidationActions {
|
||||
initialize: (data: RowData[], fields: Field<string>[], file?: File) => Promise<void>;
|
||||
setFields: (fields: Field<string>[]) => void;
|
||||
setFieldOptionsLoaded: (loaded: boolean) => void;
|
||||
setMappingSignature: (signature: MappingSignature | null) => void;
|
||||
/**
|
||||
* Merge freshly-mapped rows into the existing edited rows on back-nav re-entry.
|
||||
* Only `changedFieldKeys` are overwritten (from the matching __sourceRow), and
|
||||
* `clearedFieldKeys` are removed; all other fields keep their edits.
|
||||
*/
|
||||
reconcileMappedData: (
|
||||
freshRows: RowData[],
|
||||
changedFieldKeys: string[],
|
||||
clearedFieldKeys: string[],
|
||||
) => void;
|
||||
|
||||
// === Row Operations ===
|
||||
updateCell: (rowIndex: number, field: string, value: unknown) => void;
|
||||
@@ -538,4 +553,10 @@ export interface ValidationStepProps {
|
||||
onBack?: () => void;
|
||||
onNext?: (data: CleanRowData[]) => void;
|
||||
isFromScratch?: boolean;
|
||||
/**
|
||||
* Signature describing how columns were mapped. Used on back-nav re-entry to decide
|
||||
* whether to preserve edits (merge changed fields only) vs. fully re-initialize, and
|
||||
* whether UPC validation needs to re-run. Undefined for from-scratch / restored flows.
|
||||
*/
|
||||
mappingSignature?: MappingSignature;
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ const getInitialState = (): ValidationState => ({
|
||||
|
||||
// Initialization
|
||||
initPhase: 'idle',
|
||||
mappingSignature: null,
|
||||
|
||||
// AI Validation
|
||||
aiValidation: {
|
||||
@@ -207,6 +208,67 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setMappingSignature: (signature) => {
|
||||
set((state) => {
|
||||
state.mappingSignature = signature;
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Merge freshly-mapped rows into the existing (edited) rows when returning from
|
||||
* Match Columns. Aligns by __sourceRow so edits, selection, and __index are
|
||||
* preserved; only fields whose mapping changed are overwritten, and fields that
|
||||
* became unmapped are cleared. Rows deleted in Validation are not re-added.
|
||||
*/
|
||||
reconcileMappedData: (freshRows: RowData[], changedFieldKeys: string[], clearedFieldKeys: string[]) => {
|
||||
if (changedFieldKeys.length === 0 && clearedFieldKeys.length === 0) return;
|
||||
|
||||
set((state) => {
|
||||
// Price fields are stripped of $/commas on ingestion; mirror that here so
|
||||
// re-pulled values match how they were cleaned during initialize().
|
||||
const priceFieldKeys = new Set(
|
||||
state.fields
|
||||
.filter((f) => f.fieldType.type === 'input' && 'price' in f.fieldType && f.fieldType.price)
|
||||
.map((f) => f.key),
|
||||
);
|
||||
const cleanValue = (key: string, value: unknown) =>
|
||||
priceFieldKeys.has(key) && typeof value === 'string' && value !== ''
|
||||
? stripPriceFormatting(value)
|
||||
: value;
|
||||
|
||||
// Index fresh rows by their stable source-row id.
|
||||
const freshBySource = new Map<number, RowData>();
|
||||
for (const row of freshRows) {
|
||||
if (typeof row.__sourceRow === 'number') {
|
||||
freshBySource.set(row.__sourceRow, row);
|
||||
}
|
||||
}
|
||||
|
||||
state.rows.forEach((row, idx) => {
|
||||
const fresh = typeof row.__sourceRow === 'number' ? freshBySource.get(row.__sourceRow) : undefined;
|
||||
|
||||
// Overwrite changed fields from the freshly-mapped source row.
|
||||
if (fresh) {
|
||||
for (const key of changedFieldKeys) {
|
||||
const value = cleanValue(key, fresh[key]);
|
||||
row[key] = value;
|
||||
if (state.originalRows[idx]) {
|
||||
state.originalRows[idx][key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear fields that are no longer mapped.
|
||||
for (const key of clearedFieldKeys) {
|
||||
row[key] = undefined;
|
||||
if (state.originalRows[idx]) {
|
||||
state.originalRows[idx][key] = undefined;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
// =========================================================================
|
||||
// Row Operations
|
||||
// =========================================================================
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Mapping signature utilities
|
||||
*
|
||||
* When the user navigates back to Match Columns and forward again, we need to know
|
||||
* *what* about the mapping actually changed so the Validation store can:
|
||||
* - preserve edits for fields whose source did not change,
|
||||
* - overwrite only the fields whose source did change,
|
||||
* - re-run UPC validation / item-number generation only when the supplier or the
|
||||
* UPC column mapping changed.
|
||||
*
|
||||
* A "signature" is a compact, comparable description of how every field is sourced.
|
||||
*/
|
||||
|
||||
import { ColumnType, type Columns, type GlobalSelections } from '../../MatchColumnsStep/types';
|
||||
|
||||
export interface MappingSignature {
|
||||
/** field key -> a string describing where that field's value comes from */
|
||||
perField: Record<string, string>;
|
||||
/** resolved supplier (global selection), surfaced for UPC re-run gating */
|
||||
supplier: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a single column's contribution to the signature.
|
||||
* matchedOptions are folded in so re-mapping a select value counts as a change.
|
||||
*/
|
||||
const encodeColumn = (column: Columns<string>[number], costIsTotalCost?: boolean): string => {
|
||||
let sig = `col:${column.index}:${column.type}`;
|
||||
|
||||
if ('matchedOptions' in column && Array.isArray(column.matchedOptions)) {
|
||||
const opts = column.matchedOptions
|
||||
.map((o) => `${o.entry ?? ''}=>${o.value ?? ''}`)
|
||||
.join('|');
|
||||
sig += `:opts(${opts})`;
|
||||
}
|
||||
|
||||
// cost_each is post-processed by the "divide by min qty" flag, so the resulting
|
||||
// value differs even when the source column is identical — fold the flag in.
|
||||
if ('value' in column && column.value === 'cost_each' && costIsTotalCost) {
|
||||
sig += ':total';
|
||||
}
|
||||
|
||||
return sig;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a signature from the current columns + global selections.
|
||||
*/
|
||||
export const computeMappingSignature = (
|
||||
columns: Columns<string>,
|
||||
globalSelections?: GlobalSelections,
|
||||
): MappingSignature => {
|
||||
const perField: Record<string, string> = {};
|
||||
|
||||
for (const column of columns) {
|
||||
if (
|
||||
column.type === ColumnType.empty ||
|
||||
column.type === ColumnType.ignored ||
|
||||
column.type === ColumnType.aiSupplemental
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if ('value' in column) {
|
||||
perField[column.value] = encodeColumn(column, globalSelections?.costIsTotalCost);
|
||||
}
|
||||
}
|
||||
|
||||
// Global selections act as the source for these fields when not column-mapped.
|
||||
const globalKeys: (keyof GlobalSelections)[] = ['supplier', 'company', 'line', 'subline'];
|
||||
for (const key of globalKeys) {
|
||||
const val = globalSelections?.[key];
|
||||
if (val && !perField[key]) {
|
||||
perField[key] = `global:${String(val)}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
perField,
|
||||
supplier: String(globalSelections?.supplier ?? ''),
|
||||
};
|
||||
};
|
||||
|
||||
export interface MappingDiff {
|
||||
/** true when nothing relevant changed (safe to skip re-init entirely) */
|
||||
equal: boolean;
|
||||
/** fields whose source changed (or were newly mapped) — values should be re-pulled */
|
||||
changedFieldKeys: string[];
|
||||
/** fields that were mapped before but are now unmapped — values should be cleared */
|
||||
clearedFieldKeys: string[];
|
||||
/** whether UPC validation / item-number generation needs to re-run */
|
||||
upcAffected: boolean;
|
||||
}
|
||||
|
||||
const UPC_INPUT_FIELDS = ['supplier', 'upc', 'barcode'];
|
||||
|
||||
/**
|
||||
* Compare two signatures and describe what changed.
|
||||
*/
|
||||
export const diffMappingSignatures = (
|
||||
prev: MappingSignature | null | undefined,
|
||||
next: MappingSignature,
|
||||
): MappingDiff => {
|
||||
// No previous signature => treat everything as changed (first-time init handles this
|
||||
// path separately, but be safe).
|
||||
if (!prev) {
|
||||
return {
|
||||
equal: false,
|
||||
changedFieldKeys: Object.keys(next.perField),
|
||||
clearedFieldKeys: [],
|
||||
upcAffected: true,
|
||||
};
|
||||
}
|
||||
|
||||
const changedFieldKeys: string[] = [];
|
||||
const clearedFieldKeys: string[] = [];
|
||||
|
||||
const allKeys = new Set([...Object.keys(prev.perField), ...Object.keys(next.perField)]);
|
||||
for (const key of allKeys) {
|
||||
const before = prev.perField[key];
|
||||
const after = next.perField[key];
|
||||
if (before === after) continue;
|
||||
if (after === undefined) {
|
||||
clearedFieldKeys.push(key);
|
||||
} else {
|
||||
changedFieldKeys.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const upcAffected = [...changedFieldKeys, ...clearedFieldKeys].some((k) =>
|
||||
UPC_INPUT_FIELDS.includes(k),
|
||||
);
|
||||
|
||||
return {
|
||||
equal: changedFieldKeys.length === 0 && clearedFieldKeys.length === 0,
|
||||
changedFieldKeys,
|
||||
clearedFieldKeys,
|
||||
upcAffected,
|
||||
};
|
||||
};
|
||||
@@ -109,7 +109,7 @@ export default function BulkEdit() {
|
||||
|
||||
// Load field options on mount (but don't auto-load products)
|
||||
useEffect(() => {
|
||||
axios
|
||||
apiClient
|
||||
.get("/api/import/field-options")
|
||||
.then((res) => setFieldOptions(res.data))
|
||||
.catch((err) => {
|
||||
@@ -127,7 +127,7 @@ export default function BulkEdit() {
|
||||
setSublineOptions([]);
|
||||
if (!lineCompany) return;
|
||||
setIsLoadingLines(true);
|
||||
axios
|
||||
apiClient
|
||||
.get(`/api/import/product-lines/${lineCompany}`)
|
||||
.then((res) => setLineOptions(res.data))
|
||||
.catch(() => setLineOptions([]))
|
||||
@@ -140,7 +140,7 @@ export default function BulkEdit() {
|
||||
setSublineOptions([]);
|
||||
if (!lineLine) return;
|
||||
setIsLoadingSublines(true);
|
||||
axios
|
||||
apiClient
|
||||
.get(`/api/import/sublines/${lineLine}`)
|
||||
.then((res) => setSublineOptions(res.data))
|
||||
.catch(() => setSublineOptions([]))
|
||||
@@ -303,7 +303,7 @@ export default function BulkEdit() {
|
||||
if (pidsNeedingImages.length === 0) return;
|
||||
|
||||
pidsNeedingImages.forEach((pid) => {
|
||||
axios
|
||||
apiClient
|
||||
.get(`/api/import/product-images/${pid}`)
|
||||
.then((res) => {
|
||||
const images = res.data;
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user