Compare commits
2 Commits
4e4a09ee3f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 718eb3f54c | |||
| 2a8d9c473b |
@@ -1,4 +1,3 @@
|
||||
* Avoid using glob tool for search as it may not work properly on this codebase. Search using bash instead.
|
||||
* If you use the task tool to have an agent investigate something, make sure to let it know to avoid using glob
|
||||
* Prefer solving tasks in a single session. Only spawn subagents for genuinely independent workstreams.
|
||||
* The postgres/query tool is not working and not connected to the current version of the database. If you need to query the database for any reason you can use "ssh netcup" and use psql on the server with inventory_readonly 6D3GUkxuFgi2UghwgnUd
|
||||
@@ -0,0 +1,202 @@
|
||||
// LIVE PM2 config for netcup. Deployed copy: /var/www/ecosystem.config.cjs
|
||||
//
|
||||
// This repo file is the source of truth; the deployed copy is what PM2 actually
|
||||
// evaluates. Keep them in sync — edit here, then copy across (see "To apply").
|
||||
// Note /var/www itself is root-owned, so you cannot CREATE files there without
|
||||
// sudo, but /var/www/ecosystem.config.cjs is matt:matt and edits in place fine.
|
||||
//
|
||||
// All apps run under matt's single PM2 daemon (no sudo, no root daemon).
|
||||
// Log paths follow the per-server `logs/pm2/` convention (each service directory
|
||||
// already has the folder created); `pm2-logrotate` rotates them in place.
|
||||
//
|
||||
// ─── READ THIS BEFORE CHANGING ANYTHING ──────────────────────────────────────
|
||||
// `pm2 save` IS NOT OPTIONAL. systemd's pm2-matt.service runs
|
||||
// `ExecStart=pm2 resurrect`, which rebuilds every app's env from
|
||||
// ~/.pm2/dump.pm2 — NOT from this file and NOT from .env. Only `pm2 save`
|
||||
// writes that dump. A reload you didn't save is a time bomb, not a fix.
|
||||
//
|
||||
// That unit gets bounced without any reboot: on 2026-07-28 06:57 an
|
||||
// unattended-upgrade of libc6 triggered needrestart, which restarted
|
||||
// pm2-matt.service, which resurrected a MAY-23 dump. Two consequences:
|
||||
// * The Phase 6.4 JWT_SECRET unification (applied + verified 2026-07-20 but
|
||||
// never saved) silently reverted. new-auth-server went back to an old
|
||||
// 128-char secret while inventory-server kept the correct .env one, so
|
||||
// login/`/me` returned 200 while every /api/* call 401'd "Invalid token"
|
||||
// and bounced users to the login screen.
|
||||
// * dashboard-server was absent from that old dump entirely, so nothing came
|
||||
// up on :3015 and all five Caddy vendor paths 502'd for ~9 hours.
|
||||
// Both fixed 2026-07-28 and `pm2 save`d. Audit the dump periodically — it drifts
|
||||
// in both directions (it still listed four apps deleted months earlier).
|
||||
//
|
||||
// Also note `--update-env` MERGES; it cannot delete a key. A var inherited from
|
||||
// an old dump survives every reload. Only `pm2 delete` + `start` clears it.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Design decisions worth preserving (CONSOLIDATION_PLAN.md §4, §6.4, §6.10):
|
||||
// 1. NO `JWT_SECRET: process.env.JWT_SECRET` override in new-auth-server's env
|
||||
// block. That override shadowed the .env value with whatever shell var was
|
||||
// exported when pm2 was last started. With it gone, .env is the single
|
||||
// source. Do not reintroduce it.
|
||||
// 2. Log paths live in per-service `logs/pm2/...` (matt:matt), not
|
||||
// `/var/log/pm2/...` where matt has no write perms.
|
||||
// 3. Phase 4: four per-vendor apps (klaviyo-server, meta-server, google-server,
|
||||
// typeform-server) collapsed into a single `dashboard-server` on :3015.
|
||||
// Those four are long gone — deleted, and purged from the dump 2026-07-28.
|
||||
// 4. Phase 6.10 (2026-05-24): `ADD_WORD_TOKEN` is NOT inline on lt-wordlist-api;
|
||||
// `node_args: ['--env-file=/opt/lt-wordlist-api/.env']` lets Node ≥20.6 read
|
||||
// it natively at startup. To rotate: edit that .env, then
|
||||
// `pm2 restart lt-wordlist-api --update-env`.
|
||||
// 5. Script paths corrected during apply (both were wrong in the original plan):
|
||||
// lt-wordlist-api → /opt/lt-wordlist-api/index.js (was server.js)
|
||||
// acot-phone-server → /var/www/acot-phone/dist/server.js
|
||||
// (was ./inventory/acot-phone/server.js)
|
||||
//
|
||||
// To apply a change:
|
||||
// cp /var/www/ecosystem.config.cjs ~/backups/ecosystem.config.cjs.bak.$(date +%F)
|
||||
// cp /var/www/inventory/deploy/ecosystem.config.cjs /var/www/ecosystem.config.cjs
|
||||
// node --check /var/www/ecosystem.config.cjs # syntax gate before reload
|
||||
// pm2 reload /var/www/ecosystem.config.cjs --update-env [--only <app>]
|
||||
// pm2 save # ← REQUIRED, see above
|
||||
//
|
||||
// Verify after reload:
|
||||
// pm2 list
|
||||
// pm2 env new-auth-server | grep -i jwt # JWT_SECRET from .env only
|
||||
// pm2 env lt-wordlist-api | grep ADD_WORD # empty (loaded from /opt/.env)
|
||||
// # confirm the dump actually took, not just the running processes:
|
||||
// python3 -c "import json;print([a['name'] for a in json.load(open('/home/matt/.pm2/dump.pm2'))])"
|
||||
|
||||
const inventoryEnv = require('dotenv').config({ path: '/var/www/inventory/.env' }).parsed;
|
||||
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'new-auth-server', // Phase 8 may rename to 'auth-server' — cosmetic
|
||||
script: './inventory/auth/server.js',
|
||||
cwd: '/var/www',
|
||||
env: {
|
||||
...inventoryEnv,
|
||||
NODE_ENV: 'production',
|
||||
TZ: 'America/Chicago', // business time (see inventory/docs/TIME.md)
|
||||
AUTH_PORT: 3011,
|
||||
// PHASE 6.4 FIX: no JWT_SECRET override here. .env wins.
|
||||
},
|
||||
max_memory_restart: '500M',
|
||||
error_file: './inventory/auth/logs/pm2/new-auth-server-error.log',
|
||||
out_file: './inventory/auth/logs/pm2/new-auth-server-out.log',
|
||||
},
|
||||
{
|
||||
name: 'inventory-server',
|
||||
script: './inventory/src/server.js',
|
||||
cwd: '/var/www',
|
||||
env: {
|
||||
...inventoryEnv,
|
||||
NODE_ENV: 'production',
|
||||
TZ: 'America/Chicago', // business time (see inventory/docs/TIME.md)
|
||||
PORT: 3010,
|
||||
UPLOADS_DIR: '/var/www/inventory/uploads',
|
||||
},
|
||||
max_memory_restart: '1G',
|
||||
error_file: './inventory/logs/pm2/inventory-server-error.log',
|
||||
out_file: './inventory/logs/pm2/inventory-server-out.log',
|
||||
},
|
||||
{
|
||||
name: 'chat-server',
|
||||
script: './inventory/chat/server.js',
|
||||
cwd: '/var/www',
|
||||
env: { ...inventoryEnv, NODE_ENV: 'production', TZ: 'America/Chicago', PORT: 3014 },
|
||||
max_memory_restart: '500M',
|
||||
error_file: './inventory/chat/logs/pm2/chat-server-error.log',
|
||||
out_file: './inventory/chat/logs/pm2/chat-server-out.log',
|
||||
},
|
||||
{
|
||||
name: 'acot-server',
|
||||
script: './inventory/dashboard/acot-server/server.js',
|
||||
cwd: '/var/www',
|
||||
env: { ...inventoryEnv, NODE_ENV: 'production', TZ: 'America/Chicago', ACOT_PORT: 3012 },
|
||||
max_memory_restart: '1G',
|
||||
error_file: './inventory/dashboard/acot-server/logs/pm2/acot-server-error.log',
|
||||
out_file: './inventory/dashboard/acot-server/logs/pm2/acot-server-out.log',
|
||||
},
|
||||
// Phase 4: merged ESM dashboard-server. Replaced klaviyo-server (3004),
|
||||
// meta-server (3005), google-server (3007) and typeform-server (3008); those
|
||||
// four are deleted and purged from the dump. Cutover is COMPLETE — Caddy has
|
||||
// pointed the vendor paths at :3015 since 2026-07-21.
|
||||
//
|
||||
// Now serves FIVE routers, not four (see dashboard/server.js):
|
||||
// /api/klaviyo /api/meta /api/dashboard-analytics /api/typeform
|
||||
// /api/freescout ← added 2026-07-18 for the CS dashboard
|
||||
// All five are proxied here by Caddy. Memory cap = sum of the four old caps
|
||||
// minus dedup'd Redis client + Pool overhead, rounded up.
|
||||
//
|
||||
// Adding a vendor needs NO change here — it's another router inside this same
|
||||
// process. Vendor credentials deliberately do NOT live in this file: server.js
|
||||
// layers /var/www/inventory/.env first, then dashboard/.env, with dotenv
|
||||
// override:false so shared security vars win and vendor keys stay per-service.
|
||||
// FREESCOUT_DB_* / ACOT_PHONE_DB_* therefore live in dashboard/.env only.
|
||||
//
|
||||
// Fail-soft gotcha: without FREESCOUT_DB_HOST the route is simply not mounted
|
||||
// (404s, no startup error). The only signal is the log line
|
||||
// "FREESCOUT_DB_* not set — /api/freescout not mounted".
|
||||
//
|
||||
// Health check: curl -fsS http://localhost:3015/health
|
||||
{
|
||||
name: 'dashboard-server',
|
||||
script: './inventory/dashboard/server.js',
|
||||
cwd: '/var/www',
|
||||
env: {
|
||||
...inventoryEnv,
|
||||
NODE_ENV: 'production',
|
||||
TZ: 'America/Chicago', // business time (see inventory/docs/TIME.md)
|
||||
DASHBOARD_PORT: 3015,
|
||||
},
|
||||
max_memory_restart: '1G',
|
||||
error_file: './inventory/dashboard/logs/pm2/dashboard-server-error.log',
|
||||
out_file: './inventory/dashboard/logs/pm2/dashboard-server-out.log',
|
||||
},
|
||||
// Script entry is index.js (NOT server.js — earlier proposed value was wrong).
|
||||
// PORT MUST be set explicitly to 3030 (Caddy's `/lt-wordlist/*` block proxies
|
||||
// there) — otherwise pm2 inherits PORT=3010 from the parent shell / inventory
|
||||
// .env, the script's `process.env.PORT || 3030` picks up 3010, lt-wordlist
|
||||
// squats on inventory-server's port, and inventory-server crashes with
|
||||
// EADDRINUSE. Caught during apply 2026-05-24.
|
||||
//
|
||||
// Phase 6.10 (applied 2026-05-24, Deviation #25): ADD_WORD_TOKEN is loaded via
|
||||
// Node's native `--env-file` (supported on Node ≥20.6 — netcup runs v22). The
|
||||
// token lives in /opt/lt-wordlist-api/.env (matt:matt 0600), NEVER in this
|
||||
// file. The script reads `process.env.ADD_WORD_TOKEN` directly; the prior
|
||||
// `'tokenhere'` insecure fallback no longer applies because the env var is
|
||||
// always set from the file. To rotate: edit /opt/lt-wordlist-api/.env, then
|
||||
// `pm2 restart lt-wordlist-api --update-env`.
|
||||
{
|
||||
name: 'lt-wordlist-api',
|
||||
script: '/opt/lt-wordlist-api/index.js',
|
||||
cwd: '/opt/lt-wordlist-api',
|
||||
node_args: ['--env-file=/opt/lt-wordlist-api/.env'],
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3030,
|
||||
},
|
||||
max_memory_restart: '200M',
|
||||
error_file: '/opt/lt-wordlist-api/logs/pm2/lt-wordlist-api-error.log',
|
||||
out_file: '/opt/lt-wordlist-api/logs/pm2/lt-wordlist-api-out.log',
|
||||
},
|
||||
// Lives in a separate repo at /var/www/acot-phone/ (matt:matt). The compiled
|
||||
// entrypoint is dist/server.js. Loads its own /var/www/acot-phone/.env at boot
|
||||
// (PORT=3020 there) but dotenv defaults to override:false, so any PORT already
|
||||
// set in the pm2 env (e.g. inherited from inventory's .env=3010) WINS — same
|
||||
// EADDRINUSE-on-3010 footgun as lt-wordlist-api. Set PORT explicitly here.
|
||||
// Caddy proxies phone.acot.site → :3020.
|
||||
{
|
||||
name: 'acot-phone-server',
|
||||
script: '/var/www/acot-phone/dist/server.js',
|
||||
cwd: '/var/www/acot-phone',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
PORT: 3020,
|
||||
},
|
||||
max_memory_restart: '300M',
|
||||
error_file: '/var/www/acot-phone/logs/pm2/acot-phone-server-error.log',
|
||||
out_file: '/var/www/acot-phone/logs/pm2/acot-phone-server-out.log',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1232,7 +1232,9 @@ router.get('/search-products', async (req, res) => {
|
||||
if (pid) {
|
||||
const pids = String(pid).split(',').map(Number).filter(n => !isNaN(n) && n > 0);
|
||||
if (pids.length === 0) {
|
||||
connection.release();
|
||||
// NOTE: do not release/end the connection here — getDbConnection() returns a
|
||||
// single shared, cached mysql2 PromiseConnection (no .release()), not a pool
|
||||
// connection. Closing it would break every other in-flight request.
|
||||
return res.status(400).json({ error: 'Invalid pid parameter' });
|
||||
}
|
||||
if (pids.length === 1) {
|
||||
@@ -1437,6 +1439,41 @@ router.get('/search-products', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Current warehouse locations for a batch of pids — a single-table read kept
|
||||
// deliberately tiny. Added for the email app's printable count sheet: shelf
|
||||
// locations move after putaway, so the sheet pulls them LIVE at print time
|
||||
// instead of trusting its receipt mirror's snapshot. GET = authenticated-only,
|
||||
// same as the other read endpoints here.
|
||||
router.get('/product-locations', async (req, res) => {
|
||||
const pids = String(req.query.pids || '')
|
||||
.split(',')
|
||||
.map(Number)
|
||||
.filter((n) => Number.isInteger(n) && n > 0);
|
||||
if (pids.length === 0) {
|
||||
return res.status(400).json({ error: 'pids (comma-separated positive integers) is required' });
|
||||
}
|
||||
if (pids.length > 1000) {
|
||||
return res.status(400).json({ error: 'Too many pids (max 1000)' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { connection } = await getDbConnection();
|
||||
const [rows] = await connection.query(
|
||||
`SELECT pid, location FROM products WHERE pid IN (${pids.map((p) => connection.escape(p)).join(',')})`
|
||||
);
|
||||
// Empty-string locations (unshelved products) are omitted rather than sent
|
||||
// as '' so callers can treat presence as "has a shelf location".
|
||||
const locations = {};
|
||||
for (const row of rows) {
|
||||
if (row.location) locations[row.pid] = row.location;
|
||||
}
|
||||
res.json({ locations });
|
||||
} catch (error) {
|
||||
console.error('Error fetching product locations:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch product locations' });
|
||||
}
|
||||
});
|
||||
|
||||
// Shared SELECT for product queries (matches search-products fields)
|
||||
const PRODUCT_SELECT = `
|
||||
SELECT
|
||||
|
||||
Generated
+3
-3
@@ -4278,9 +4278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001766",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz",
|
||||
"integrity": "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==",
|
||||
"version": "1.0.30001806",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
|
||||
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ import { formatCurrency } from "./SalesChart.jsx";
|
||||
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases";
|
||||
import config from "@/config";
|
||||
import { apiFetch } from "@/utils/api";
|
||||
import { formatBusinessDay } from "@/utils/businessTime";
|
||||
import {
|
||||
DashboardStatCardMini,
|
||||
DashboardStatCardMiniSkeleton,
|
||||
@@ -93,11 +94,8 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
refetchInterval: 300000,
|
||||
});
|
||||
|
||||
const formatXAxis = (value) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString([], { month: "numeric", day: "numeric" });
|
||||
};
|
||||
const formatXAxis = (value) =>
|
||||
formatBusinessDay(value, { month: "numeric", day: "numeric" });
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -212,7 +210,11 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
content={({ active, payload }) => {
|
||||
if (!active || !payload?.length) return null;
|
||||
const dateStr = payload[0]?.payload?.date;
|
||||
const date = dateStr ? new Date(dateStr) : null;
|
||||
const dateLabel = formatBusinessDay(dateStr, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const styles = TOOLTIP_THEMES.stone;
|
||||
const items = payload
|
||||
.filter((entry) => entry.value > 0)
|
||||
@@ -220,15 +222,7 @@ const MiniSalesChart = ({ className = "" }) => {
|
||||
const total = items.reduce((sum, entry) => sum + (entry.value || 0), 0);
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{date && (
|
||||
<p className={styles.header}>
|
||||
{date.toLocaleDateString([], {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{dateLabel && <p className={styles.header}>{dateLabel}</p>}
|
||||
<div className={styles.content}>
|
||||
{items.map((entry, index) => {
|
||||
const cfg = PHASE_CONFIG[entry.dataKey] || {};
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
DashboardErrorState,
|
||||
MetricPill,
|
||||
} from "@/components/dashboard/shared";
|
||||
import { formatBusinessDay } from "@/utils/businessTime";
|
||||
|
||||
// Chart color mapping — Sorbet Studio series hues (prev-* stay dashed in the
|
||||
// chart; the lighter prev tones are only legal alongside that dash)
|
||||
@@ -77,14 +78,12 @@ const salesValueFormatter = (value, name) => {
|
||||
};
|
||||
|
||||
// Sales chart label formatter - formats timestamp as readable date
|
||||
const salesLabelFormatter = (label) => {
|
||||
const date = new Date(label);
|
||||
return date.toLocaleDateString("en-US", {
|
||||
const salesLabelFormatter = (label) =>
|
||||
formatBusinessDay(label, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const calculate7DayAverage = (data) => {
|
||||
if (!Array.isArray(data) || data.length === 0) return [];
|
||||
@@ -391,11 +390,8 @@ const SalesChart = ({ timeRange = "last30days", title = "Sales" }) => {
|
||||
};
|
||||
}, [selectedTimeRange, fetchData]);
|
||||
|
||||
const formatXAxis = (value) => {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
};
|
||||
const formatXAxis = (value) =>
|
||||
formatBusinessDay(value, { month: "short", day: "numeric" });
|
||||
|
||||
const averageRevenue =
|
||||
data.length > 0
|
||||
|
||||
@@ -21,7 +21,8 @@ import { acotService } from "@/services/dashboard/acotService";
|
||||
import { apiClient } from "@/utils/apiClient";
|
||||
import { apiFetch } from "@/utils/api";
|
||||
import config from "@/config";
|
||||
import { PHASE_CONFIG, PHASE_KEYS_WITH_UNKNOWN as PHASE_KEYS } from "@/utils/lifecyclePhases";
|
||||
import { PHASE_CONFIG } from "@/utils/lifecyclePhases";
|
||||
import { formatBusinessDay } from "@/utils/businessTime";
|
||||
// @ts-expect-error - JSX module without type declarations
|
||||
import { processBasicData } from "@/components/dashboard/RealtimeAnalytics";
|
||||
// @ts-expect-error - JSX module without type declarations
|
||||
@@ -176,6 +177,7 @@ interface Projection {
|
||||
}
|
||||
|
||||
interface DailyRow {
|
||||
date?: string;
|
||||
revenue?: number;
|
||||
orders?: number;
|
||||
prevRevenue?: number;
|
||||
@@ -183,6 +185,15 @@ interface DailyRow {
|
||||
periodProgress?: number;
|
||||
}
|
||||
|
||||
/** Period totals summed across DailyRow[] — every field is always populated. */
|
||||
interface DailyTotals {
|
||||
revenue: number;
|
||||
orders: number;
|
||||
prevRevenue: number;
|
||||
prevOrders: number;
|
||||
periodProgress: number;
|
||||
}
|
||||
|
||||
interface FinancialsResponse {
|
||||
totals?: Record<string, number>;
|
||||
previousTotals?: Record<string, number>;
|
||||
@@ -249,7 +260,7 @@ const NightboardSmall = () => {
|
||||
daily: true,
|
||||
})) as { stats?: DailyRow[] };
|
||||
const rows = Array.isArray(response.stats) ? response.stats : [];
|
||||
const t = rows.reduce<Required<DailyRow>>(
|
||||
const t = rows.reduce<DailyTotals>(
|
||||
(acc, day) => ({
|
||||
revenue: acc.revenue + (Number(day.revenue) || 0),
|
||||
orders: acc.orders + (Number(day.orders) || 0),
|
||||
@@ -264,6 +275,9 @@ const NightboardSmall = () => {
|
||||
...t,
|
||||
avgPerDay: t.revenue / days,
|
||||
prevAvgPerDay: t.prevRevenue / days,
|
||||
// keep the per-day rows — the chart below plots them, so the curve and
|
||||
// the totals in the header are the same numbers from the same fetch
|
||||
daily: rows,
|
||||
};
|
||||
},
|
||||
refetchInterval: 300_000,
|
||||
@@ -276,9 +290,13 @@ const NightboardSmall = () => {
|
||||
refetchInterval: 300_000,
|
||||
});
|
||||
|
||||
// 30-day chart + phase mix (same endpoint as MiniSalesChart)
|
||||
// Lifecycle-phase mix ONLY. The daily curve comes from the live acot feed
|
||||
// (see sum30) — this endpoint reads the Postgres mirror, which is a copy the
|
||||
// import refreshes on a schedule, so it must never drive a "live" number.
|
||||
// Phase mix has no live equivalent: lifecycle_phase lives in product_metrics,
|
||||
// which is computed inventory-side and has no counterpart in the ACOT DB.
|
||||
const { data: chartData } = useQuery({
|
||||
queryKey: ["nightboard-sales-chart-30d"],
|
||||
queryKey: ["nightboard-phase-mix-30d"],
|
||||
queryFn: async () => {
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now);
|
||||
@@ -439,12 +457,10 @@ const NightboardSmall = () => {
|
||||
: sum30?.orders;
|
||||
const orders30Trend = sum30 ? trendPct(orders30Current ?? 0, sum30.prevOrders) : null;
|
||||
|
||||
const dailyTotals: { date: string; total: number }[] = (chartData?.dailySalesByPhase || []).map(
|
||||
(day: { date: string } & Record<string, unknown>) => ({
|
||||
date: day.date,
|
||||
total: PHASE_KEYS.reduce((sum, key) => sum + (Number(day[key]) || 0), 0),
|
||||
})
|
||||
);
|
||||
const dailyTotals: { date: string; total: number }[] = (sum30?.daily || []).map((day) => ({
|
||||
date: day.date ?? "",
|
||||
total: Number(day.revenue) || 0,
|
||||
}));
|
||||
const activePhases: PhaseSlice[] = (chartData?.phaseBreakdown || [])
|
||||
.filter((p: PhaseSlice) => p.revenue > 0)
|
||||
.sort((a: PhaseSlice, b: PhaseSlice) => b.revenue - a.revenue);
|
||||
@@ -655,7 +671,7 @@ const NightboardSmall = () => {
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tickFormatter={(v: string) =>
|
||||
new Date(v).toLocaleDateString([], { month: "numeric", day: "numeric" })
|
||||
formatBusinessDay(v, { month: "numeric", day: "numeric" })
|
||||
}
|
||||
tick={{ fill: NB.mut, fontSize: 16 }}
|
||||
tickLine={false}
|
||||
@@ -680,7 +696,7 @@ const NightboardSmall = () => {
|
||||
style={{ background: NB.card, border: `1px solid ${NB.line}`, color: NB.txt }}
|
||||
>
|
||||
<div style={{ color: NB.mut }}>
|
||||
{new Date(row.date).toLocaleDateString([], {
|
||||
{formatBusinessDay(row.date, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
|
||||
+28
-13
@@ -279,6 +279,10 @@ export const useDragAndDrop = ({
|
||||
|
||||
// Effect to register browser-level drag events on product containers
|
||||
useEffect(() => {
|
||||
// Collect the cleanups: returning from inside forEach() does nothing, so the
|
||||
// listeners used to accumulate on every re-run instead of being removed.
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
// For each product container
|
||||
data.forEach((_, index) => {
|
||||
const container = document.getElementById(`product-${index}`);
|
||||
@@ -290,39 +294,50 @@ export const useDragAndDrop = ({
|
||||
setActiveDroppableId(`product-${index}`);
|
||||
};
|
||||
|
||||
// Functional update so this effect doesn't need activeDroppableId as a
|
||||
// dependency - otherwise it re-binds every listener on each hover change
|
||||
// in the middle of a drag.
|
||||
const handleNativeDragLeave = () => {
|
||||
if (activeDroppableId === `product-${index}`) {
|
||||
setActiveDroppableId(null);
|
||||
}
|
||||
setActiveDroppableId(current => (current === `product-${index}` ? null : current));
|
||||
};
|
||||
|
||||
// Add these handlers
|
||||
container.addEventListener('dragover', handleNativeDragOver);
|
||||
container.addEventListener('dragleave', handleNativeDragLeave);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
cleanups.push(() => {
|
||||
container.removeEventListener('dragover', handleNativeDragOver);
|
||||
container.removeEventListener('dragleave', handleNativeDragLeave);
|
||||
};
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [data, productImages, activeDroppableId]); // Re-run when data or productImages change
|
||||
|
||||
return () => cleanups.forEach(cleanup => cleanup());
|
||||
}, [data, productImages]); // Re-run when data or productImages change
|
||||
|
||||
// Function to add more visual indication when dragging
|
||||
//
|
||||
// IMPORTANT: the drag affordance must never change an element's box size.
|
||||
// dnd-kit measures every droppable exactly once, in an effect that runs right
|
||||
// after the drag starts, and it only re-measures on *resize* (ResizeObserver),
|
||||
// never on *position* changes. A border added here on drag start grows every
|
||||
// card by 2px, pushing everything below it down after the measurement was
|
||||
// taken - which silently offsets every drop target by 2px x (cards above) and
|
||||
// makes reordering drop into the wrong row. Outlines are painted outside the
|
||||
// box model, so they highlight without moving anything.
|
||||
const getProductContainerClasses = (index: number) => {
|
||||
const isValidDropTarget = activeId && findContainer(activeId) !== index.toString();
|
||||
const isActiveDropTarget = activeDroppableId === `product-${index}`;
|
||||
|
||||
return [
|
||||
"flex-1 min-h-[6rem] rounded-md p-2 transition-all",
|
||||
// Only show borders during active drag operations
|
||||
"flex-1 min-h-[6rem] rounded-md p-2 outline-dashed outline-2 -outline-offset-2 transition-colors",
|
||||
// Only show the outline during active drag operations
|
||||
isValidDropTarget && isActiveDropTarget
|
||||
? "border-2 border-dashed border-primary bg-primary/10"
|
||||
? "outline-primary bg-primary/10"
|
||||
: isValidDropTarget
|
||||
? "border border-dashed border-muted-foreground/30"
|
||||
: ""
|
||||
].filter(Boolean).join(" ");
|
||||
? "outline-muted-foreground/30"
|
||||
: "outline-transparent"
|
||||
].join(" ");
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
+40
-14
@@ -8,7 +8,8 @@
|
||||
|
||||
import { memo, useEffect, useState, useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { X, ArrowDownToLine } from 'lucide-react';
|
||||
import { X, ArrowDownToLine, Eraser } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useValidationStore } from '../store/validationStore';
|
||||
import { useIsCopyDownActive } from '../store/selectors';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -28,6 +29,8 @@ export const CopyDownBanner = memo(() => {
|
||||
// These are cheap to compare and only change while copy-down is active.
|
||||
const rowCount = useValidationStore((state) => state.rows.length);
|
||||
const sourceRowIndex = useValidationStore((state) => state.copyDownMode.sourceRowIndex);
|
||||
// Empty source = this run clears the target cells instead of filling them
|
||||
const isClearing = useValidationStore((state) => state.copyDownMode.isClearing);
|
||||
const rowsBelow = sourceRowIndex !== null ? Math.max(0, rowCount - 1 - sourceRowIndex) : 0;
|
||||
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
|
||||
const bannerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -114,42 +117,65 @@ export const CopyDownBanner = memo(() => {
|
||||
}}
|
||||
>
|
||||
<div ref={bannerRef} className="pointer-events-auto">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 animate-in fade-in slide-in-from-top-2 duration-200">
|
||||
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span className="text-xs font-medium text-blue-700 whitespace-nowrap">
|
||||
Click row to copy to
|
||||
<div className={cn(
|
||||
'rounded-full shadow-lg pl-3 pr-1 py-1 flex items-center gap-2 border animate-in fade-in slide-in-from-top-2 duration-200',
|
||||
isClearing ? 'bg-amber-50 border-amber-200' : 'bg-blue-50 border-blue-200'
|
||||
)}>
|
||||
<div className={cn(
|
||||
'w-1.5 h-1.5 rounded-full animate-pulse',
|
||||
isClearing ? 'bg-amber-500' : 'bg-blue-500'
|
||||
)} />
|
||||
<span className={cn(
|
||||
'text-xs font-medium whitespace-nowrap',
|
||||
isClearing ? 'text-amber-700' : 'text-blue-700'
|
||||
)}>
|
||||
{isClearing ? 'Click row to clear to' : 'Click row to copy to'}
|
||||
</span>
|
||||
{rowsBelow > 0 && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-blue-200" />
|
||||
<div className={cn('h-4 w-px', isClearing ? 'bg-amber-200' : 'bg-blue-200')} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleApplyToAll}
|
||||
className="h-6 px-2 text-xs font-medium text-blue-700 hover:text-blue-900 hover:bg-blue-100"
|
||||
className={cn(
|
||||
'h-6 px-2 text-xs font-medium',
|
||||
isClearing
|
||||
? 'text-amber-700 hover:text-amber-900 hover:bg-amber-100'
|
||||
: 'text-blue-700 hover:text-blue-900 hover:bg-blue-100'
|
||||
)}
|
||||
>
|
||||
<ArrowDownToLine className="h-3 w-3 mr-1" />
|
||||
{isClearing
|
||||
? <Eraser className="h-3 w-3 mr-1" />
|
||||
: <ArrowDownToLine className="h-3 w-3 mr-1" />}
|
||||
All
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center">
|
||||
{rowsBelow === 1
|
||||
? "Copy to 1 row below"
|
||||
: `Copy to all ${rowsBelow} rows below`}
|
||||
{isClearing
|
||||
? (rowsBelow === 1
|
||||
? 'Clear this field on 1 row below'
|
||||
: `Clear this field on all ${rowsBelow} rows below`)
|
||||
: (rowsBelow === 1
|
||||
? 'Copy to 1 row below'
|
||||
: `Copy to all ${rowsBelow} rows below`)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleCancel}
|
||||
className="h-6 w-6 p-0 text-blue-600 hover:text-blue-800 hover:bg-blue-100"
|
||||
className={cn(
|
||||
'h-6 w-6 p-0',
|
||||
isClearing
|
||||
? 'text-amber-600 hover:text-amber-800 hover:bg-amber-100'
|
||||
: 'text-blue-600 hover:text-blue-800 hover:bg-blue-100'
|
||||
)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
+60
-15
@@ -17,7 +17,7 @@ import { apiFetch } from '@/utils/api';
|
||||
import { type ColumnDef } from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ArrowDown, Wand2, Loader2, Calculator, Scale, Pin, PinOff } from 'lucide-react';
|
||||
import { ArrowDown, Eraser, Wand2, Loader2, Calculator, Scale, Pin, PinOff } from 'lucide-react';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -134,6 +134,8 @@ interface CellWrapperProps {
|
||||
isCopyDownSource: boolean;
|
||||
isInCopyDownRange: boolean;
|
||||
isCopyDownTarget: boolean;
|
||||
/** Active copy-down has an empty source, i.e. it clears the targets */
|
||||
isCopyDownClearing: boolean;
|
||||
totalRowCount: number;
|
||||
// Inline AI validation (Groq-powered)
|
||||
inlineAiSuggestion?: InlineAiSuggestion;
|
||||
@@ -162,6 +164,7 @@ const CellWrapper = memo(({
|
||||
isCopyDownSource,
|
||||
isInCopyDownRange,
|
||||
isCopyDownTarget,
|
||||
isCopyDownClearing,
|
||||
totalRowCount,
|
||||
inlineAiSuggestion,
|
||||
isInlineAiValidating = false,
|
||||
@@ -200,19 +203,41 @@ const CellWrapper = memo(({
|
||||
// Check if cell has a value (for showing copy-down button)
|
||||
const hasValue = value !== undefined && value !== null && value !== '';
|
||||
|
||||
// Check if field has unique validation rule (copy-down should be disabled)
|
||||
// Blank source = copy-down CLEARS the cells below. Matches isBlankCellValue()
|
||||
// in the store, so the button's look always matches what the copy will do.
|
||||
const isBlankValue = !hasValue ||
|
||||
(typeof value === 'string' && value.trim() === '') ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
// Check if field has unique validation rule (values can't be duplicated down it)
|
||||
const hasUniqueValidation = field.validations?.some((v: Validation) => v.rule === 'unique') ?? false;
|
||||
|
||||
// Check if cell has errors (for positioning copy-down button)
|
||||
const hasErrors = errors.length > 0;
|
||||
|
||||
// The cells themselves suppress the error icon on an empty cell whose only error
|
||||
// is "required" (see showErrorIcon in InputCell/SelectCell/MultiSelectCell) - the
|
||||
// red border carries the message instead. Mirror that rule, or the copy/clear
|
||||
// button dodges an icon that was never drawn and drifts toward the middle.
|
||||
const showsErrorIcon = hasErrors &&
|
||||
!(isBlankValue && errors.length === 1 && errors[0]?.type === ErrorType.Required);
|
||||
|
||||
// Show copy-down button when:
|
||||
// - Cell is hovered
|
||||
// - Cell has a value
|
||||
// - Not already in copy-down mode
|
||||
// - There are rows below this one
|
||||
// - Field does NOT have unique validation (can't copy unique values)
|
||||
const showCopyDownButton = isHovered && hasValue && !isCopyDownActive && rowIndex < totalRowCount - 1 && !hasUniqueValidation;
|
||||
// - Field does NOT have unique validation, OR we're clearing
|
||||
//
|
||||
// An EMPTY cell is a valid source: copying a blank down clears the cells below,
|
||||
// which is the only bulk way to wipe a column of values that shouldn't be there.
|
||||
// Clearing is safe even on unique fields (emptying rows can't create duplicates)
|
||||
// and on required fields (the cleared cells simply flag as required again).
|
||||
const showCopyDownButton = isHovered && !isCopyDownActive && rowIndex < totalRowCount - 1 &&
|
||||
(!hasUniqueValidation || isBlankValue);
|
||||
|
||||
// Keep the copy/clear button in the cell's usual hover slot at the right edge,
|
||||
// stepping left only when an error icon is actually occupying that slot.
|
||||
const copyDownRightClass = showsErrorIcon ? 'right-7' : 'right-0.5';
|
||||
|
||||
// UPC Generation logic
|
||||
const isUpcField = field.key === 'upc';
|
||||
@@ -223,6 +248,14 @@ const CellWrapper = memo(({
|
||||
const hasValidSupplier = /^\d+$/.test(supplierIdString);
|
||||
const showGenerateUpcButton = isHovered && upcIsEmpty && !isValidating && !isCopyDownActive && !isGeneratingUpc;
|
||||
|
||||
// An empty UPC cell hosts both hover buttons; the copy/clear button owns the
|
||||
// right edge, so the wider generate-UPC button parks to its left.
|
||||
const generateUpcRightClass = !showCopyDownButton
|
||||
? 'right-1'
|
||||
: showsErrorIcon
|
||||
? 'right-14'
|
||||
: 'right-7';
|
||||
|
||||
// Handle starting copy-down
|
||||
const handleStartCopyDown = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -868,11 +901,17 @@ const CellWrapper = memo(({
|
||||
}, [needsCompany, needsLine, company, line]);
|
||||
|
||||
// Determine cell highlighting classes
|
||||
// Amber for a clearing run (empty source) so the preview range reads as
|
||||
// "these get wiped", not "these get filled"
|
||||
const cellHighlightClass = cn(
|
||||
'relative w-full group',
|
||||
isCopyDownSource && 'ring-2 ring-blue-500 ring-inset rounded',
|
||||
isInCopyDownRange && 'bg-blue-100',
|
||||
isCopyDownTarget && !isInCopyDownRange && 'hover:bg-blue-50 cursor-pointer'
|
||||
isCopyDownSource && (isCopyDownClearing
|
||||
? 'ring-2 ring-amber-500 ring-inset rounded'
|
||||
: 'ring-2 ring-blue-500 ring-inset rounded'),
|
||||
isInCopyDownRange && (isCopyDownClearing ? 'bg-amber-100' : 'bg-blue-100'),
|
||||
isCopyDownTarget && !isInCopyDownRange && (isCopyDownClearing
|
||||
? 'hover:bg-amber-50 cursor-pointer'
|
||||
: 'hover:bg-blue-50 cursor-pointer')
|
||||
);
|
||||
|
||||
// When in copy-down mode for this field, make cell non-interactive so clicks go to parent
|
||||
@@ -932,18 +971,22 @@ const CellWrapper = memo(({
|
||||
onClick={handleStartCopyDown}
|
||||
className={cn(
|
||||
'absolute top-1/2 -translate-y-1/2 z-10 p-1 rounded-full',
|
||||
'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
|
||||
'',
|
||||
'shadow-sm',
|
||||
// Position further left if there are errors to avoid overlap
|
||||
hasErrors ? 'right-7' : 'right-0.5'
|
||||
// Clearing is destructive - give it its own colour so it can't be
|
||||
// mistaken for a normal copy at a glance
|
||||
isBlankValue
|
||||
? 'bg-amber-50 hover:bg-amber-100 text-amber-600 hover:text-amber-700'
|
||||
: 'bg-blue-50 hover:bg-blue-100 text-blue-500 hover:text-blue-600',
|
||||
copyDownRightClass
|
||||
)}
|
||||
>
|
||||
<ArrowDown className="h-3.5 w-3.5" />
|
||||
{isBlankValue
|
||||
? <Eraser className="h-3.5 w-3.5" />
|
||||
: <ArrowDown className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Copy value to rows below
|
||||
{isBlankValue ? 'Clear this field on rows below' : 'Copy value to rows below'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -959,7 +1002,8 @@ const CellWrapper = memo(({
|
||||
onClick={handleGenerateUpc}
|
||||
disabled={!hasValidSupplier}
|
||||
className={cn(
|
||||
'absolute right-1 top-1/2 -translate-y-1/2 z-10 flex items-center gap-1',
|
||||
'absolute top-1/2 -translate-y-1/2 z-10 flex items-center gap-1',
|
||||
generateUpcRightClass,
|
||||
'rounded-md border border-input bg-background px-2 py-1 text-xs shadow-sm',
|
||||
'opacity-0 group-hover:opacity-100 transition-opacity',
|
||||
hasValidSupplier
|
||||
@@ -1568,6 +1612,7 @@ const VirtualRow = memo(({
|
||||
isCopyDownSource={isCopyDownSource}
|
||||
isInCopyDownRange={isInCopyDownRange}
|
||||
isCopyDownTarget={isCopyDownTarget}
|
||||
isCopyDownClearing={copyDownMode.isClearing}
|
||||
totalRowCount={totalRowCount}
|
||||
inlineAiSuggestion={inlineAiSuggestion}
|
||||
isInlineAiValidating={
|
||||
|
||||
+53
@@ -2,6 +2,8 @@
|
||||
* useCopyDownValidation Hook
|
||||
*
|
||||
* Watches for copy-down operations and triggers appropriate validations:
|
||||
* - Every copied field -> re-run its field rules on the touched rows
|
||||
* (so copying a BLANK down raises "required" errors on the cleared cells)
|
||||
* - UPC-related fields (supplier, upc, barcode) -> UPC validation
|
||||
* - Line field -> Inline AI validation for rows that gain sufficient context
|
||||
*
|
||||
@@ -13,6 +15,8 @@ import { useEffect } from 'react';
|
||||
import { apiFetch } from '@/utils/api';
|
||||
import { useValidationStore } from '../store/validationStore';
|
||||
import { useUpcValidation } from './useUpcValidation';
|
||||
import { validateFieldValue } from './useValidationActions';
|
||||
import { ErrorSource, ErrorType } from '../store/types';
|
||||
import type { Field } from '../../../types';
|
||||
import {
|
||||
buildNameValidationPayload,
|
||||
@@ -80,10 +84,59 @@ export const useCopyDownValidation = () => {
|
||||
|
||||
// Subscribe to pending validations
|
||||
const pendingUpcValidation = useValidationStore((state) => state.pendingCopyDownValidation);
|
||||
const pendingRevalidation = useValidationStore((state) => state.pendingCopyDownRevalidation);
|
||||
const pendingInlineAiValidation = useValidationStore((state) => state.pendingInlineAiValidation);
|
||||
const clearPendingCopyDownValidation = useValidationStore((state) => state.clearPendingCopyDownValidation);
|
||||
const clearPendingCopyDownRevalidation = useValidationStore((state) => state.clearPendingCopyDownRevalidation);
|
||||
const clearPendingInlineAiValidation = useValidationStore((state) => state.clearPendingInlineAiValidation);
|
||||
|
||||
// Re-run field rules on the copied rows.
|
||||
// Copying a value down clears errors optimistically in the store; this pass is
|
||||
// what handles the opposite direction — a copied BLANK must re-raise "required"
|
||||
// errors and downgrade the row status, exactly as clearing the cell by hand does.
|
||||
// PERFORMANCE: results are collected first and written in ONE store update,
|
||||
// since "Apply to All" can touch every row in the sheet.
|
||||
useEffect(() => {
|
||||
if (!pendingRevalidation) return;
|
||||
|
||||
const { fieldKey, affectedRows } = pendingRevalidation;
|
||||
const { rows, fields, errors, applyFieldValidationResults } = useValidationStore.getState();
|
||||
|
||||
const field = fields.find((f) => f.key === fieldKey);
|
||||
if (field) {
|
||||
// Uniqueness is a cross-row rule: clearing a cell can resolve a duplicate that
|
||||
// was flagged on a row we never touched. Re-check those rows too, but ONLY the
|
||||
// ones showing an in-sheet duplicate error — never rows carrying a UPC-sourced
|
||||
// error, which reflects the live catalog and can't be settled from the sheet.
|
||||
const isUniqueField = field.validations?.some((v) => v.rule === 'unique') ?? false;
|
||||
const rowsToCheck = isUniqueField
|
||||
? Array.from(new Set([
|
||||
...affectedRows,
|
||||
...rows.reduce<number[]>((acc, _row, rowIndex) => {
|
||||
const stale = errors.get(rowIndex)?.[fieldKey]?.some(
|
||||
(e) => e.type === ErrorType.Unique && e.source !== ErrorSource.Upc
|
||||
);
|
||||
if (stale) acc.push(rowIndex);
|
||||
return acc;
|
||||
}, []),
|
||||
])).sort((a, b) => a - b)
|
||||
: affectedRows;
|
||||
|
||||
const results = rowsToCheck
|
||||
.filter((rowIndex) => rows[rowIndex])
|
||||
.map((rowIndex) => ({
|
||||
rowIndex,
|
||||
error: validateFieldValue(rows[rowIndex][fieldKey], field, rows, rowIndex),
|
||||
}));
|
||||
|
||||
if (results.length > 0) {
|
||||
applyFieldValidationResults(fieldKey, results);
|
||||
}
|
||||
}
|
||||
|
||||
clearPendingCopyDownRevalidation();
|
||||
}, [pendingRevalidation, clearPendingCopyDownRevalidation]);
|
||||
|
||||
// Handle UPC validation
|
||||
useEffect(() => {
|
||||
if (!pendingUpcValidation) return;
|
||||
|
||||
+4
-1
@@ -30,8 +30,11 @@ const isEmpty = (value: unknown): boolean => {
|
||||
|
||||
/**
|
||||
* Validate a single field value against its validation rules
|
||||
*
|
||||
* Exported so bulk callers (e.g. copy-down re-validation) can reuse the exact
|
||||
* same rule evaluation without pulling in the per-row store writes.
|
||||
*/
|
||||
const validateFieldValue = (
|
||||
export const validateFieldValue = (
|
||||
value: unknown,
|
||||
field: Field<string>,
|
||||
allRows: RowData[],
|
||||
|
||||
@@ -151,6 +151,8 @@ export interface CopyDownState {
|
||||
sourceRowIndex: number | null;
|
||||
sourceFieldKey: string | null;
|
||||
targetRowIndex: number | null; // Hover preview - which row the user is hovering on
|
||||
/** Source cell is empty, so completing the copy CLEARS the target cells */
|
||||
isClearing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,6 +164,16 @@ export interface PendingCopyDownValidation {
|
||||
affectedRows: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks rows that need their field re-validated after copy-down completes.
|
||||
* Unlike the optimistic error-clearing done inline, this runs the real rule set,
|
||||
* so copying a BLANK value down surfaces "required" errors on the cleared cells.
|
||||
*/
|
||||
export interface PendingCopyDownRevalidation {
|
||||
fieldKey: string;
|
||||
affectedRows: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks rows that need inline AI validation after line copy-down.
|
||||
* When line is copied to rows that already have company + name/description,
|
||||
@@ -390,6 +402,7 @@ export interface ValidationState {
|
||||
// === Copy-Down Mode ===
|
||||
copyDownMode: CopyDownState;
|
||||
pendingCopyDownValidation: PendingCopyDownValidation | null;
|
||||
pendingCopyDownRevalidation: PendingCopyDownRevalidation | null;
|
||||
pendingInlineAiValidation: PendingInlineAiValidation | null;
|
||||
|
||||
// === Dialogs ===
|
||||
@@ -450,6 +463,10 @@ export interface ValidationActions {
|
||||
allErrors: Map<number, Record<string, ValidationError[]>>,
|
||||
allStatuses: Map<number, RowValidationStatus>
|
||||
) => void;
|
||||
applyFieldValidationResults: (
|
||||
fieldKey: string,
|
||||
results: Array<{ rowIndex: number; error: ValidationError | null }>
|
||||
) => void;
|
||||
clearRowErrors: (rowIndex: number) => void;
|
||||
clearFieldError: (rowIndex: number, field: string) => void;
|
||||
setRowValidationStatus: (rowIndex: number, status: RowValidationStatus) => void;
|
||||
@@ -496,6 +513,7 @@ export interface ValidationActions {
|
||||
completeCopyDown: (targetRowIndex: number) => void;
|
||||
setTargetRowHover: (rowIndex: number | null) => void;
|
||||
clearPendingCopyDownValidation: () => void;
|
||||
clearPendingCopyDownRevalidation: () => void;
|
||||
clearPendingInlineAiValidation: () => void;
|
||||
|
||||
// === Dialogs ===
|
||||
|
||||
+96
-5
@@ -57,8 +57,16 @@ const initialCopyDownState: CopyDownState = {
|
||||
sourceRowIndex: null,
|
||||
sourceFieldKey: null,
|
||||
targetRowIndex: null,
|
||||
isClearing: false,
|
||||
};
|
||||
|
||||
/** A cell counts as empty (and so clears its targets) when it has no usable value */
|
||||
const isBlankCellValue = (value: unknown): boolean =>
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
(typeof value === 'string' && value.trim() === '') ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
// Fields that require UPC validation when changed via copy-down
|
||||
const UPC_VALIDATION_FIELDS = ['supplier', 'upc', 'barcode'];
|
||||
|
||||
@@ -111,6 +119,7 @@ const getInitialState = (): ValidationState => ({
|
||||
// Copy-Down Mode
|
||||
copyDownMode: { ...initialCopyDownState },
|
||||
pendingCopyDownValidation: null,
|
||||
pendingCopyDownRevalidation: null,
|
||||
pendingInlineAiValidation: null,
|
||||
|
||||
// Dialogs
|
||||
@@ -450,6 +459,46 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* PERFORMANCE: Apply one field's validation outcome across many rows in a
|
||||
* SINGLE store update. Used by copy-down, where "Apply to All" can touch
|
||||
* hundreds of rows and per-row setError() calls would re-clone the errors
|
||||
* Map every time.
|
||||
*/
|
||||
applyFieldValidationResults: (
|
||||
fieldKey: string,
|
||||
results: Array<{ rowIndex: number; error: ValidationError | null }>
|
||||
) => {
|
||||
set((state) => {
|
||||
for (const { rowIndex, error } of results) {
|
||||
const rowErrors = state.errors.get(rowIndex);
|
||||
|
||||
if (error) {
|
||||
state.errors.set(rowIndex, { ...(rowErrors ?? {}), [fieldKey]: [error] });
|
||||
state.rowValidationStatus.set(rowIndex, 'error');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rowErrors && rowErrors[fieldKey]) {
|
||||
const remaining = { ...rowErrors };
|
||||
delete remaining[fieldKey];
|
||||
if (Object.keys(remaining).length === 0) {
|
||||
state.errors.delete(rowIndex);
|
||||
} else {
|
||||
state.errors.set(rowIndex, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
// Only promote rows that were already carrying a status — never mark a
|
||||
// never-validated row "validated" off the back of a single field.
|
||||
const stillHasErrors = Object.keys(state.errors.get(rowIndex) ?? {}).length > 0;
|
||||
if (!stillHasErrors && state.rowValidationStatus.has(rowIndex)) {
|
||||
state.rowValidationStatus.set(rowIndex, 'validated');
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
clearRowErrors: (rowIndex: number) => {
|
||||
set((state) => {
|
||||
state.errors.delete(rowIndex);
|
||||
@@ -677,6 +726,9 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
sourceRowIndex: rowIndex,
|
||||
sourceFieldKey: fieldKey,
|
||||
targetRowIndex: null,
|
||||
// Resolved once at start so the banner and range highlight can warn
|
||||
// that this run wipes the target cells rather than filling them
|
||||
isClearing: isBlankCellValue(state.rows[rowIndex]?.[fieldKey]),
|
||||
};
|
||||
});
|
||||
},
|
||||
@@ -698,8 +750,10 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
|
||||
// First, perform the copy operation
|
||||
set((state) => {
|
||||
const sourceValue = state.rows[sourceRowIndex]?.[fieldKey];
|
||||
if (sourceValue === undefined) return;
|
||||
const sourceRow = state.rows[sourceRowIndex];
|
||||
if (!sourceRow) return;
|
||||
|
||||
const sourceValue = sourceRow[fieldKey];
|
||||
|
||||
// Clone value for arrays/objects to prevent reference sharing
|
||||
const cloneValue = (val: unknown): unknown => {
|
||||
@@ -709,15 +763,23 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
};
|
||||
|
||||
// Check if value is non-empty (for clearing required errors)
|
||||
const hasValue = sourceValue !== null && sourceValue !== '' &&
|
||||
!(Array.isArray(sourceValue) && sourceValue.length === 0);
|
||||
const hasValue = !isBlankCellValue(sourceValue);
|
||||
|
||||
// Copying a BLANK source clears the target cells. "Blank" arrives in several
|
||||
// shapes here (undefined for an unmapped column, null, '', []), and writing
|
||||
// undefined/null back would leave the target cell uncontrolled — so write the
|
||||
// canonical empty value for whatever shape the target already holds.
|
||||
const valueForTarget = (targetValue: unknown): unknown => {
|
||||
if (hasValue) return cloneValue(sourceValue);
|
||||
return Array.isArray(targetValue) ? [] : '';
|
||||
};
|
||||
|
||||
// Track affected rows for UPC validation
|
||||
const affectedRows: number[] = [];
|
||||
|
||||
for (let i = sourceRowIndex + 1; i <= targetRowIndex; i++) {
|
||||
if (state.rows[i]) {
|
||||
state.rows[i][fieldKey] = cloneValue(sourceValue);
|
||||
state.rows[i][fieldKey] = valueForTarget(state.rows[i][fieldKey]);
|
||||
affectedRows.push(i);
|
||||
|
||||
// Clear validation errors for this field if value is non-empty
|
||||
@@ -739,6 +801,29 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
// Reset copy-down mode
|
||||
state.copyDownMode = { ...initialCopyDownState };
|
||||
|
||||
// Re-run the field's validation rules on every touched row. This is what
|
||||
// makes a blank copy-down behave like manually clearing each cell:
|
||||
// "required" errors appear instead of the cells looking silently valid.
|
||||
if (affectedRows.length > 0) {
|
||||
state.pendingCopyDownRevalidation = { fieldKey, affectedRows };
|
||||
}
|
||||
|
||||
// A cleared name/description invalidates any AI suggestion built from the
|
||||
// old text, so drop those suggestions rather than leave them stranded.
|
||||
if (!hasValue && (fieldKey === 'name' || fieldKey === 'description')) {
|
||||
const field = fieldKey as 'name' | 'description';
|
||||
for (const rowIdx of affectedRows) {
|
||||
const productIndex = state.rows[rowIdx]?.__index;
|
||||
if (!productIndex) continue;
|
||||
const existing = state.inlineAi.suggestions.get(productIndex);
|
||||
if (!existing?.[field]) continue;
|
||||
state.inlineAi.suggestions.set(productIndex, {
|
||||
...existing,
|
||||
[field]: undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If this field affects UPC validation, store the affected rows
|
||||
// so a hook can trigger validation using the existing validateUpc function
|
||||
if (UPC_VALIDATION_FIELDS.includes(fieldKey) && affectedRows.length > 0) {
|
||||
@@ -801,6 +886,12 @@ export const useValidationStore = create<ValidationStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
clearPendingCopyDownRevalidation: () => {
|
||||
set((state) => {
|
||||
state.pendingCopyDownRevalidation = null;
|
||||
});
|
||||
},
|
||||
|
||||
clearPendingInlineAiValidation: () => {
|
||||
set((state) => {
|
||||
state.pendingInlineAiValidation = null;
|
||||
|
||||
@@ -31,3 +31,45 @@ export function toDateOnly(date: Date): string {
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a server-supplied business date for DISPLAY, without shifting a day.
|
||||
*
|
||||
* The APIs hand back business days as bare 'YYYY-MM-DD' (pg via formatDateCol,
|
||||
* mysql2 via dateStrings) — and `new Date('2026-07-28')` is spec'd to parse as
|
||||
* UTC midnight, which renders as Jul 27 in any zone west of UTC. Appending a
|
||||
* time part makes the same string parse as LOCAL midnight, so the calendar date
|
||||
* round-trips through toLocaleDateString().
|
||||
*
|
||||
* Midnight-Z timestamps ('2026-07-28T00:00:00.000Z') stand for a whole business
|
||||
* day too, so they get the same treatment. Anything else is a real instant and
|
||||
* is parsed as-is.
|
||||
*/
|
||||
export function parseBusinessDay(value: string | number | Date | null | undefined): Date | null {
|
||||
if (value == null) return null;
|
||||
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
|
||||
if (typeof value === 'number') return new Date(value);
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Bare business date, or a midnight-Z timestamp standing in for one.
|
||||
const dayOnly = /^(\d{4}-\d{2}-\d{2})(?:T00:00:00(?:\.000)?Z)?$/.exec(trimmed);
|
||||
if (dayOnly) {
|
||||
const parsed = new Date(`${dayOnly[1]}T00:00:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
const parsed = new Date(trimmed);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||
}
|
||||
|
||||
/** Format a server-supplied business day for display; '' when unparseable. */
|
||||
export function formatBusinessDay(
|
||||
value: string | number | Date | null | undefined,
|
||||
options: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric' },
|
||||
locales: Intl.LocalesArgument = 'en-US'
|
||||
): string {
|
||||
const parsed = parseBusinessDay(value);
|
||||
return parsed ? parsed.toLocaleDateString(locales, options) : '';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user