Author SHA1 Message Date
Christian Vidal WolfandClaude Fable 5 0d50fce790 feat(matrix): support new matrix layout with 4 added columns
- Update default column indices to July 2026 layout (EmpCO_Compliant,
  Comments, Categorisation Code, Ingredients added; PM Classification
  removed; downstream columns shifted)
- Seed internal Categorisation Code from the new Excel column while
  keeping app-side edits as the source of truth
- Guard legacy CPNP index-77 fallbacks: only trusted when the current
  layout still holds CPNP there (new layout stores Item To Root Units)
- Use resolved column index for forced zero stock instead of static one

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 13:28:47 +02:00
Christian Vidal Wolf 633f523fc8 feat(bc): sync categorization code 2026-07-02 15:49:52 +02:00
Christian Vidal Wolf 9e19a8cace chore(backup): disable scheduled backups 2026-05-29 11:02:52 +02:00
Christian Vidal Wolf 97130003d8 fix(bc): sync all pending queue items 2026-05-27 11:14:24 +02:00
Christian Vidal Wolf 8ea63f60c8 fix(auth): resolve concurrent session token refresh and state syncing issues to prevent frequent logouts 2026-05-26 12:49:22 +02:00
Christian Vidal Wolf 070da5ad0f fix(auth): reduce forced logout 2026-05-26 11:36:11 +02:00
Christian Vidal Wolf 178d34bc45 refactor(ui): remove excel export buttons 2026-05-26 11:30:12 +02:00
Christian Vidal Wolf 4037f7924a feat(bc): sync 40hc pricing 2026-05-26 10:34:11 +02:00
Christian Vidal Wolf 74d4d37769 feat(bc): sync pricing units2 2026-05-26 10:25:37 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 c64ce84e44 fix(dashboard): enable scroll on small screens in Control Dashboard
Removed h-full and overflow-auto from the root grid div. h-full was
constraining the grid to the viewport height, causing CSS grid to
shrink rows to fit instead of overflowing. Parent <main> already has
overflow-auto, so removing the height constraint lets content expand
naturally and scroll correctly on small screens.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-26 09:51:23 +02:00
Christian Vidal Wolf 5537fea03e fix(pricing): edit units inline 2026-05-25 16:24:28 +02:00
Christian Vidal Wolf 9c3e26c523 fix(dropbox): use direct raw fallback 2026-05-21 14:08:41 +02:00
Christian Vidal Wolf 290b298544 fix(sync): harden Dropbox loading 2026-05-21 13:58:52 +02:00
Christian Vidal Wolf fa998ec9bf fix(dev): restore Dropbox file proxy 2026-05-21 13:56:13 +02:00
Christian Vidal Wolf cffd16acc7 fix(sync): pin Dropbox source 2026-05-21 13:53:12 +02:00
Christian Vidal Wolf 9c0a7d18a0 fix(sync): trust Dropbox data 2026-05-21 13:37:12 +02:00
Christian Vidal Wolf 972fbffc1c fix: update dropbox-sync API to merge non-editable fields, fix setCors crash and update fallback asset 2026-05-21 11:10:43 +02:00
Christian Vidal Wolf efe51c7f62 fix: disable capacity columns override and fix Supabase service key fallback in API endpoints 2026-05-21 10:41:20 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 295f9d3060 fix(dropbox): use authenticated API to bypass CDN cache on file download
The proxy was calling getAccessToken() but discarding the result, then
downloading via the public sharing link which is CDN-cached. Updated
data in Dropbox could take minutes/hours to propagate through the CDN,
causing all fields in the Matrix view to show stale values.

Now attempts download via /2/sharing/get_shared_link_file with Bearer
auth (no CDN cache), falling back to the sharing link if credentials
are not configured.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-21 10:28:46 +02:00
Christian Vidal Wolf ab7dceb81f update bundled data matrix fallback 2026-05-20 15:16:14 +02:00
Christian Vidal Wolf d40bc9fa9c restore legacy cpnp history index 2026-05-20 15:12:36 +02:00
Christian Vidal Wolf 528d859742 restore cosmetic cpnp merge logic 2026-05-20 15:06:12 +02:00
Christian Vidal Wolf 1d5f918a0a show all cosmetic items by cpnp filter 2026-05-20 15:02:48 +02:00
Christian Vidal Wolf 39a3588078 improve cpnp history restore 2026-05-20 15:00:26 +02:00
Christian Vidal Wolf cb913b9d72 restore cpnp values from history 2026-05-20 14:56:55 +02:00
Christian Vidal Wolf d17db2761b fix user validation fallback 2026-05-20 14:04:02 +02:00
Christian Vidal Wolf 396ff23d49 fix dropbox load fallback 2026-05-20 13:59:35 +02:00
Christian Vidal Wolf e8d05069f3 fix vercel load errors 2026-05-20 13:57:11 +02:00
Christian Vidal Wolf 88dc894050 fix(vercel): remove manual rewrites, rely on Vercel auto-detection of /api/ functions 2026-05-20 13:48:08 +02:00
Christian Vidal Wolf 4612f13968 feat(dev): support Vercel serverless API handlers locally in Vite dev server 2026-05-20 13:40:51 +02:00
Christian Vidal Wolf b2c6bce8eb fix(vercel): add leading slashes to rewrite destinations 2026-05-20 13:38:19 +02:00
Christian Vidal Wolf 47b9303202 feat: implement manual user validation and user deletion flow 2026-05-20 13:33:37 +02:00
Christian Vidal Wolf 1d105e19ae feat(topbar): add Discard button to BC sync queue dropdown 2026-05-15 13:52:47 +02:00
Christian Vidal Wolf 6d664c26f1 feat(history): add Discard selected button to cancel pending BC syncs 2026-05-15 13:45:44 +02:00
Christian Vidal Wolf 143f655f3c fix: commit all BC sync files missing from repo 2026-05-15 11:00:21 +02:00
Christian Vidal Wolf 85794b4b52 fix: add missing SyncStatusPill component to repo 2026-05-15 10:57:39 +02:00
Christian Vidal Wolf cf92b85d17 fix(ui): remove SyncStatusPill from ProductDescriptions SKU column 2026-05-15 10:53:46 +02:00
Christian Vidal Wolf 2944e6958c fix(history): paginate all records, number oldest=1 newest=N, display newest first 2026-05-15 10:51:01 +02:00
Christian Vidal Wolf f66295f07b feat: schedule 6h backup via GitHub Actions instead of Vercel cron 2026-05-15 08:10:39 +02:00
Christian Vidal Wolf b911ada50b feat: add 6h automated backup to Dropbox via Vercel cron 2026-05-15 08:06:54 +02:00
Christian Vidal Wolf cf531cfa40 fix(ui): remove yellow/red row backgrounds in PricingView 2026-05-14 20:27:53 +02:00
Christian Vidal Wolf e89f31d9b3 fix(ui): remove yellow/red row backgrounds, keep green only 2026-05-14 20:21:01 +02:00
Christian Vidal Wolf 008a10502b feat(history): add user filter dropdown 2026-05-14 20:15:34 +02:00
Christian Vidal Wolf 7dd899dae6 fix(history): number records oldest=1, newest=N 2026-05-14 18:37:07 +02:00
Christian Vidal Wolfandclaude-flow ca8a98c6c2 feat(history): add record numbering and column names to Change History
- Show total record count and filtered count in subtitle
- Add #N global number to each history entry row
- Add # column index inside each change detail table
- Column header already shown via headers[i] — now labeled "Column"

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-14 18:33:07 +02:00
Christian Vidal Wolfandclaude-flow 9b5932920e feat: add Business Central CPNP sync and expand history access
- Add bc-proxy and bc-export serverless API handlers
- Add businessCentral service with updateCpnpNoInBC and downloadBusinessCentralItemsExcel
- Sync cpnpNo to BC on save (CosmeticItemsView and handleSaveAll)
- Parallelize handleSaveAll with Promise.all
- Fix array index key, a11y click/keyboard handlers, autoFocus
- Grant Change History and Pending Validation access to jingying.shi@craze-group.com

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-14 17:17:10 +02:00
Christian Vidal Wolfandclaude-flow 2961d3f86a fix: persist CPNP No. across refreshes by saving as 'edited' and restoring in merge
- saveRowToSupabase now accepts optional status param (default 'pending')
- CosmeticItemsView saves CPNP with status 'edited' so resetAllPendingRows
  does not affect it and it survives the startup merge
- Re-added CPNP_NO to editableColumns so the merge restores it from Supabase

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 16:03:12 +02:00
Christian Vidal Wolfandclaude-flow a0b793b797 fix: save CPNP No. directly to Supabase on confirm, no Save All required
Bypasses the pending queue timing issue by calling saveRowToSupabase
directly after onSaveRow. Shows a spinner while saving. Alert on error.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 15:59:17 +02:00
Christian Vidal Wolfandclaude-flow 73118ea4c3 fix: show existing CPNP No. value in table cell, click to edit
Items with a CPNP number now display it in green. Empty cells still show
the dashed 'Click to fill...' button. Both are clickable to edit inline.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 12:27:00 +02:00
Christian Vidal Wolfandclaude-flow 9f3acd0631 fix: restore CPNP No. as Excel-resolved column, not virtual
CPNP_NO re-added to COLUMN_PATTERNS so resolveColumnIndices maps it to
the real Excel column index. Removed it from VIRTUAL_COLS and editableColumns
in App.tsx since data comes from the spreadsheet, not Supabase-only storage.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 12:22:14 +02:00
Christian Vidal Wolfandclaude-flow 1b4918a385 fix: prevent CPNP_NO from being overridden by dynamic column detection
CPNP_NO must stay hardcoded at index 107. Removed it from COLUMN_PATTERNS
and added guard to skip empty patterns in resolveColumnIndices.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 12:19:09 +02:00
Christian Vidal Wolfandclaude-flow 889e98b0ca fix: restore CPNP No. from Supabase and simplify column filter matching in Cosmetic Items
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 12:15:27 +02:00
Christian Vidal Wolfandclaude-flow 60224931bc fix: normalize filter comparison with trim and exact set match in Cosmetic Items tab
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 12:12:49 +02:00
Christian Vidal Wolfandclaude-flow d69acd5872 fix: skip column filter when all values selected in Cosmetic Items tab
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 12:08:21 +02:00
Christian Vidal Wolfandclaude-flow 19b02812bd feat: enable sorting and filtering on CPNP No. column in Cosmetic Items tab
Show all cosmetic products regardless of CPNP status. Column filter on
CPNP No. lets user isolate items with empty value.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 11:53:54 +02:00
Christian Vidal Wolfandclaude-flow 7d37d4777b fix: ensure saved changes persist across app refreshes by updating status logic and merge check
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-12 11:51:39 +02:00
Christian Vidal Wolf 311d74b783 fix: ensure saved changes persist across app refreshes by updating status logic and merge check 2026-05-11 11:48:11 +02:00
Christian Vidal Wolf b182ef6037 feat: enable sorting and filtering on CPNP No. column in Cosmetic Items tab 2026-05-11 08:48:32 +02:00
Christian Vidal Wolfandclaude-flow e303c759d7 feat: remove Classification column from Cosmetic Items tab
Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 13:30:59 +02:00
Christian Vidal Wolfandclaude-flow 1556aea081 feat: add Cosmetic Items tab with inline CPNP No. editing
New sidebar tab for cosmetic product lines (INKEE, BATH FUN, TOP FASHION,
SENSES, BODYNESS) filtered to items with empty CPNP No. Column filters on
Item Available allow toggling zero/non-zero stock. CPNP_NO added as virtual
column (index 107) stored in Supabase.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-05-08 13:28:39 +02:00
Christian Vidal Wolf d9b9664a9d feat: implement auto-resize textareas in EditPanel for descriptions and details 2026-05-07 08:55:18 +02:00
Christian Vidal Wolfandclaude-flow e7b954ecb4 feat: apply check via header checkbox after drag-selecting rows
After dragging to select rows, checkboxes appear in the Check Ying
and Check Anna column headers showing the count of selected rows.
Clicking one marks or unmarks that column for all selected rows,
then clears the selection. Footer shows a hint with a cancel button.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-28 09:34:42 +02:00
Christian Vidal Wolf 1c68e35ec4 feat: standardize Weight Issues filter to use ColumnFilterPopover 2026-04-28 09:34:12 +02:00
Christian Vidal Wolfandclaude-flow 8e1a533ea2 feat: drag-to-check rows in PricingView (Excel-style)
Dragging the mouse over rows now directly toggles Check Ying on all
covered rows on mouseup. The check value (on/off) is determined by
the state of the first row touched, so dragging over unchecked rows
marks them and vice versa. Rows light up blue during the drag to
show what will be affected.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-28 09:26:48 +02:00
Christian Vidal Wolf d1074f78de chore: remove temporary lint file 2026-04-28 09:22:53 +02:00
Christian Vidal Wolf db35c0233f feat: add Excel-style filters to Check columns and fix TS lint errors 2026-04-28 09:22:34 +02:00
Christian Vidal Wolf 3d89b2538a fix: add missing column patterns to types 2026-04-28 09:22:03 +02:00
Christian Vidal Wolf 69f9773410 feat: add MOQ filtering and sorting in PricingView 2026-04-28 09:21:24 +02:00
Christian Vidal Wolf 712a9dac16 feat: add MOQ column to PricingView positioned after container columns 2026-04-28 09:18:43 +02:00
Christian Vidal Wolf d23f4e625a fix: refactor tooltips to use global state to prevent crash and duplicate function error 2026-04-27 14:54:09 +02:00
Christian Vidal Wolf 601a02117f fix: use Portal for hover tooltips to prevent table container overflow clipping 2026-04-27 14:50:13 +02:00
Christian Vidal Wolf 557cb537a9 fix: render check ying and check anna hover tooltips downwards to prevent header clipping 2026-04-27 14:46:57 +02:00
Christian Vidal Wolf 5e9ab45191 refactor: use React Portals for note modals to ensure foreground visibility 2026-04-27 14:40:39 +02:00
Christian Vidal Wolf 0fa845e814 fix: increase z-index for modals and popups in PricingView to ensure foreground visibility 2026-04-27 14:37:05 +02:00
Christian Vidal Wolf a2bb2f2307 fix: improve filter popover positioning and reliability across all views 2026-04-25 09:14:25 +02:00
Christian Vidal Wolf f0f78a6126 fix: resolve Uncaught TypeError: Cannot read properties of undefined (reading 'key') by ensuring usePersistentState handles functional updates safely 2026-04-24 20:31:32 +02:00
Christian Vidal Wolf cd621de5d3 fix: implement exhaustive safety checks for filters and sorting to prevent white screen crashes 2026-04-24 20:26:36 +02:00
Christian Vidal Wolf a452283fbb fix: resolve JSX syntax error (unclosed span tag) in PricingView 2026-04-24 20:22:55 +02:00
Christian Vidal Wolf d50ecc9506 fix: add Item to Logistic filter and apply missing safety checks to dimension filters 2026-04-24 20:21:45 +02:00
Christian Vidal Wolf dcbaaea055 fix: resolve white screen crash in PricingView filters by adding comprehensive safety checks 2026-04-24 20:19:01 +02:00
Christian Vidal Wolf 127363a308 refactor: remove AI Settings button and modal from TopBar 2026-04-24 17:33:47 +02:00
Christian Vidal Wolf 4b7c1a2dad fix: hardcode Gemini API key for all users and disable personal overrides 2026-04-24 17:29:17 +02:00
Christian Vidal Wolf 9655da8164 refactor: eliminate automatic 'End of Life' (EOL) logic and yellow highlighting for OOC products with no stock 2026-04-24 17:24:47 +02:00
Christian Vidal Wolf 91fe855434 feat: allow applying filters with Enter key 2026-04-24 17:20:47 +02:00
Christian Vidal Wolf ef9cd53558 fix: resolve crash by adding missing second argument to createPortal 2026-04-24 17:15:08 +02:00
Christian Vidal Wolf d06285ddaa fix: portal all filter popovers to prevent occlusion by sticky columns 2026-04-24 17:13:12 +02:00
Christian Vidal Wolf e96d245f59 fix: ensure filter dropdowns are not covered by pinned columns 2026-04-24 17:08:23 +02:00
Christian Vidal Wolf 6057a16229 fix: remove overflow-hidden from pinned td cells to allow filter popover visibility 2026-04-24 16:21:56 +02:00
Christian Vidal Wolf 37aeee0dc7 fix: ensure filter popover z-index above pinned columns 2026-04-24 16:16:37 +02:00
Christian Vidal Wolf 92183dd942 fix: z-index filters above pinned columns 2026-04-24 16:13:30 +02:00
Christian Vidal Wolf 8d00b7372f feat: make search filters independent per tab while maintaining persistence 2026-04-24 16:09:49 +02:00
Christian Vidal Wolfandclaude-flow f2ed303fed fix: apply getHeaderStickyRank to outerH pinned column header
Completes the fix where pinned column filter popovers were hidden
behind sticky body cells. The outerH <th> was missed in the batch
and still used the lower body-level stacking rank.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-04-24 14:21:07 +02:00
Christian Vidal Wolf 917bb08d9f feat: persistent search and filters across all tabs via FilterContext 2026-04-24 14:19:09 +02:00
Christian Vidal Wolf 29d5994d6a fix: ensure filter popovers appear above pinned columns
- Add zIndex prop to ColumnFilterPopover and TextFilterPopover
- Pass higher zIndex to popovers when column is pinned (zIndex + 100)
- Filter popovers now display correctly over sticky columns
2026-04-24 14:06:32 +02:00
Christian Vidal Wolf b5e8c2fe22 feat: upgrade Gemini models to 3.1 and 2.5 series (April 2026) 2026-04-24 13:54:45 +02:00
Christian Vidal Wolf 96c37f92e2 security: restrict AI settings to authorized admin email 2026-04-24 13:47:31 +02:00
Christian Vidal Wolf 13890af125 feat: add AI Settings modal to manage API keys from the UI 2026-04-24 13:45:38 +02:00
Christian Vidal Wolf 43f94b3902 fix: include dynamic columns in sticky position/z-index calculation
- Include pricingEditableCols and containerCols in column order for pinned calculation
- Fix z-index stacking so all pinned columns stay visible while scrolling
2026-04-24 13:35:43 +02:00
Christian Vidal Wolf 1c76544e47 fix: update CSP to allow Gemini API 2026-04-24 13:31:49 +02:00
Christian Vidal Wolf 05f9f0ef10 fix: improve column pinning with proper z-index stacking
- Add getStickyRank() function for layered z-index on pinned columns
- Higher z-index for rightmost pinned columns so they stack correctly
- Apply z-index dynamically based on column order in pinned list
- All pinned columns now stay visible while scrolling
2026-04-24 13:31:36 +02:00
Christian Vidal Wolf 3bc2753bec debug: add API key source logging 2026-04-24 13:28:01 +02:00
Christian Vidal Wolf 2c1efc44d6 fix: rename Gemini API key to VITE_GEMINI_API_KEY and improve logging 2026-04-24 13:27:34 +02:00
Christian Vidal Wolf e6207dff64 feat: add column pinning (freeze) functionality in Pricing Units tab
- Add Pin Columns button to select columns to freeze
- Implement sticky columns with left positioning for pinned columns
- Support pinning dynamic pricing and container columns
- Add Pin icon indicator on pinned columns
- Include reset and unpin all options in pin panel
2026-04-24 13:23:18 +02:00
Christian Vidal Wolf b8a960e3e5 fix: enable real-time filtering for global search and fix selection bug 2026-04-24 12:43:27 +02:00
Christian Vidal Wolf a0a9fbeb2e fix: resolve syntax error in global search ternary operator 2026-04-24 12:39:47 +02:00
Christian Vidal Wolf c5d083e011 feat: implement advanced 5-term global search in PricingView 2026-04-24 12:38:11 +02:00
Christian Vidal Wolf 64babee90d feat: upgrade column filters to support up to 5 dynamic terms with AND/OR logic 2026-04-24 12:34:00 +02:00
Christian Vidal Wolf dbc47d95cb feat: implement advanced Excel-style Custom Filter for SKU and Name columns and fix search clear bug 2026-04-24 12:27:30 +02:00
Christian Vidal Wolf 6a258c3fb6 docs: clarify multi-word search in UI and apply AND logic to column name filters 2026-04-24 12:23:48 +02:00
Christian Vidal Wolf 97fe06eedd feat: implement multi-word AND search in all views and initialize RuFlo V3 2026-04-24 12:20:14 +02:00
Christian Vidal Wolf b662acddd7 fix: date formatting for Dropbox data imports 2026-04-23 21:44:26 +02:00
Christian Vidal Wolf 8ffcae5eef fix: prefer history data over synced data for internal columns 2026-04-23 19:28:25 +02:00
Christian Vidal Wolf a987a5b750 debug: add immediate log after getHistoryDataForMerge 2026-04-23 19:25:52 +02:00
Christian Vidal Wolf cf602fe9a2 debug: add detailed safeFetch and getHistoryDataForMerge logging 2026-04-23 19:23:29 +02:00
Christian Vidal Wolf 3892b9f256 debug: log processedRow[103] for 59272EN 2026-04-23 19:20:43 +02:00
Christian Vidal Wolf f15c9fa853 fix: pad rows to match extendedHeaders length so internal cols are readable 2026-04-23 19:17:03 +02:00
Christian Vidal Wolf 7ef9f62497 debug: add diagnostic logs to trace history merge for 59272EN 2026-04-23 19:14:10 +02:00
Christian Vidal Wolf c1e3960a20 fix: merge history data into rows when products table is empty 2026-04-23 19:07:38 +02:00
Christian Vidal Wolf ee94926d04 fix: HistoryEntry.id type changed from number to string (Supabase returns UUID) 2026-04-23 18:58:52 +02:00
Christian Vidal Wolf be2adbfe68 debug: add detailed logging to trace history loading 2026-04-23 18:51:07 +02:00
Christian Vidal Wolf 0eeeeb480c fix: always load history on mount and reload on data change 2026-04-23 18:47:32 +02:00
Christian Vidal Wolf 7b71aa484e fix: reload history when data refreshes from Dropbox 2026-04-23 18:43:18 +02:00
Christian Vidal Wolf e0508d50d6 fix: restore internal control columns and protect them from Dropbox sync overrides 2026-04-23 18:36:35 +02:00
Christian Vidal Wolf 5112a27cda Feat: Implement dynamic internal column protection and Fullscreen mode.
- Internal control columns (Type, Checking, etc.) now persist across Excel shifts.
- Master data columns now prioritize Dropbox source of truth.
- Added Fullscreen mode to all main tabs (Pricing, Descriptions, Matrix).
- Improved Supabase data loading with pagination.
2026-04-23 18:27:40 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 e1cfaaedfa fix: paginate getAllSyncedRows to fetch all products beyond the 1000 row Supabase cap
With 1005+ products, the old query returned only the top 1000 by updated_at.
Items edited long ago (like 51016DE) were silently missing from syncedData,
so the merge never applied their saved edits. Now paginate by product_id
until we exhaust the table.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:56:54 +02:00
Christian Vidal Wolf be7009353a temp: diagnostic log v2 for 51016DE 2026-04-23 16:54:31 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 596b779e01 fix: guard against undefined headers in MissingDataView and MatrixView
Extended headers array can have empty strings that get passed as header
values; guard all .toLowerCase().includes() calls with (h || '').

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:51:16 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 0165793b3e fix: fill sparse holes in extendedHeaders to prevent undefined.includes() crash
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:47:39 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 96c56947c0 fix: extend headers with virtual columns so saved edits at indices >Excel length render
When the Excel shrinks (fewer columns than when an edit was saved), the
saved value exists in Supabase at e.g. index 103 but headers only covers
0-76, so the column never renders. Now we pad headers up to the hardcoded
virtual column indices (PRODUCT_TYPE=103, etc.) so the merge value is
visible in the UI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:44:42 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 59e4bdeb92 fix: protect products with history from being overwritten by Dropbox sync
Products that have entries in products_history were edited at some point.
If their status was accidentally reset to 'excel', the sync would overwrite
their data. Now also check products_history to build the protected set.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:38:23 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 80f86cb83e fix: remove temp diag log
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:35:20 +02:00
Christian Vidal Wolf c3dda5d919 temp: add diag log for 51016DE Supabase data 2026-04-23 16:31:30 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 ebef518f11 fix: apply Supabase edits by diff vs Excel, not by editable column index
Instead of relying on resolvedCols.PRODUCT_TYPE (which can resolve to the
wrong index if the column header doesn't match the pattern), compare the
saved Supabase row against the fresh Excel row: any index where they differ
is a user edit and gets applied. This fixes TYPE / 'Surprise Bath bomb'
not appearing after reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:29:46 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 63242d8852 fix: resolve TYPE column with pattern ['type'] instead of ['product','type']
The column header in the Excel is 'TYPE', not 'Product Type', so the
old pattern never matched. resolveColumnIndices fell back to the hardcoded
index 103, causing the PRODUCT_TYPE editable column index to be wrong on
reload and edited values (e.g. 'Surprise Bath bomb') to never be applied.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:26:04 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 f3ed3cf147 fix: preserve edited changes after export and on Dropbox reload
resetAllPendingRows was setting status to 'synced' instead of 'edited',
causing the merge logic on reload to skip those rows. Also keep 'edited'
and 'saved' statuses locally after export instead of clearing all.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 16:22:35 +02:00
Christian Vidal Wolf 7de903a184 fix: load saved edits (status edited/pending) from Supabase 2026-04-23 16:16:20 +02:00
Christian Vidal Wolf 56fc7d34c3 fix: load fresh Excel values, only preserve pending edits 2026-04-23 16:14:18 +02:00
Christian Vidal Wolf 6bcf697165 fix: always use fresh Dropbox Excel values on load 2026-04-23 16:12:09 +02:00
Christian Vidal Wolf 605b93c445 fix: sync preserves manually edited items (status edited/pending) 2026-04-23 16:10:01 +02:00
Christian Vidal Wolf c2b229e899 fix: only preserve Supabase if value is different from Dropbox 2026-04-23 16:07:16 +02:00
Christian Vidal Wolf ba5bea44c0 fix: increase synced rows limit to 1000 2026-04-23 16:02:59 +02:00
Christian Vidal Wolf d98665fde0 fix: preserve app edits from Supabase (shows in Change History) 2026-04-23 16:00:21 +02:00
Christian Vidal Wolf 5ad1bff842 fix: use Dropbox values unless there's pending edit 2026-04-23 15:57:33 +02:00
Christian Vidal Wolf dbaf0e76fe Fix: Implement dynamic column re-alignment for data merging.
- Automatically detects if saved data uses the old Excel structure.
- Shifts indices dynamically to ensure UVP and SRP values match current columns.
- Fixes the 2.41 UVP and 4.95 SRP Int discrepancy.
2026-04-23 15:55:13 +02:00
Christian Vidal Wolf ce343a1b1f Fix: Debug API env check. 2026-04-23 15:51:40 +02:00
Christian Vidal Wolf 1b28399b76 Fix: Final attempt at migration script fix.
- Removed 'new' keyword before error object.
- Reverted to sequential processing to ensure stability.
- Improved error reporting.
2026-04-23 15:46:54 +02:00
Christian Vidal Wolf 31b2451835 Fix: Improve migration script for database re-alignment.
- Added robust error handling.
- Optimized database updates with Promise.all.
- Broadened status check to ensure all affected rows are captured.
2026-04-23 15:46:10 +02:00
Christian Vidal Wolf 54dfc24239 Fix: Re-align database indices after column insertion in Excel.
- Created a migration API route to shift data indices >= 12.
- Resolved discrepancy where UVP was showing LP Onl values due to column shift.
2026-04-23 15:43:40 +02:00
Christian Vidal Wolf 68d8d67f95 UI: Change status of restored rows from 'pending' to 'synced' to remove distracting yellow highlights. Now previously saved work appears with a normal background while still being correctly loaded from the database. 2026-04-23 15:09:00 +02:00
Christian Vidal Wolf 397023b00d Fix: Restore missing user edits by refining the data merge logic.
- Migrated 126 manual edits to 'edited' status.
- Updated App.tsx to merge rows with 'edited' or 'synced' status.
- Protected manual edits from being overwritten by automated Dropbox syncs.
2026-04-23 15:04:43 +02:00
Christian Vidal Wolf 360baa7f10 feat: increase history limit to 500 entries 2026-04-23 15:00:08 +02:00
Christian Vidal Wolf b14cfe00f0 feat: fullscreen mode for all tabs + fix Dropbox/Supabase sync priority 2026-04-23 14:53:43 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 e7a3a4619d UI: Add multi-select filter dropdown to Type column in Pricing view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:31:39 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 f545776e15 UI: Add 'Check Anna' column to Pricing Units view
Mirrors Check Ying with its own checkbox and note editor (pink theme).
Stored in virtual columns ANNA_CHECK (105) and ANNA_NOTE (106).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 13:02:14 +02:00
Christian Vidal Wolf aca37c4a99 Feature: Add ITEM TO LOGISTIC column to Pricing Units tab.
- Integrated new column after TYPE.
- Enabled inline editing with auto-save.
- Added field to the full row EditPanel.
- Configured dynamic column detection in types.ts.
2026-04-23 12:58:51 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 617eb00edb UI: Rename 'Check' column to 'Check Ying' in Pricing view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:58:11 +02:00
Christian Vidal Wolf 1c3da9024c Fix: Update Dropbox sharing link to the new version provided by the user. Restores auto-load functionality in both dev and production. 2026-04-23 12:50:40 +02:00
Christian Vidal Wolf f6ef573f3a Auth: Implement automatic session refresh logic to prevent 'JWT expired' errors. The app now handles token renewal in the background, allowing users to save changes without interruption. 2026-04-23 12:37:04 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 e0484354b9 Security: Add HTTP security headers via vercel.json
Adds HSTS, CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy,
and Permissions-Policy to all responses. CSP allowlist includes only
Supabase and Dropbox as external connect targets.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:04:33 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 2e4ab35f37 Security: Restrict API endpoints to allowed origin (CORS)
Both Vercel serverless functions now enforce CORS, returning 403 for
requests from any origin other than craze-data-check.vercel.app.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 10:02:47 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 dec24f52f4 Security: Enable RLS support by passing user JWT in Supabase requests
Replaced hardcoded anon key as Bearer token with authHeaders() helper
that uses the authenticated user's access_token when available, enabling
Row Level Security policies to identify the calling user correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 09:59:46 +02:00
Christian Vidal Wolf 7a32605189 Fix: Import missing 'cn' and 'X' in App.tsx. Resolves blank screen runtime error. 2026-04-22 20:40:32 +02:00
Christian Vidal Wolf b5ea911e43 UI: Implement Maximize mode for tables. Added a 'MAXIMIZE' button to TopBar and an 'X' close button in fullscreen mode. Hides sidebar and header to provide maximum screen space for data tables. 2026-04-22 20:38:06 +02:00
Christian Vidal Wolf bf98e4e4ba UI: Add resizable columns to Matrix View. Users can now adjust column widths by dragging, improving data visibility in the All Data view. 2026-04-22 17:09:46 +02:00
Christian Vidal Wolf f01b720c05 UI: Enable 'Enter' key to apply filters in ColumnFilterPopover. Improved search usability by allowing quick selection and application of filters from the keyboard. 2026-04-22 17:06:35 +02:00
Christian Vidal Wolf d43d9931c6 Fix: Implement dynamic column detection to handle Excel structure changes (insertion of PM Classification). This fixes the Missing data error in Pricing Units and other views. 2026-04-22 16:52:59 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 8471a69529 fix: detect Units/Outer column dynamically instead of hardcoded index
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 16:37:50 +02:00
Christian Vidal Wolf 81f0e76c3b fix: update dropbox sharing link to the new version 2026-04-22 16:17:07 +02:00
Christian Vidal Wolf eec500f737 fix: improve dropbox loading resilience and fix dev proxy 2026-04-22 16:09:00 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 e7f88c67e6 feat: add filter to Issues column in Pricing view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:39:04 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 db25ea793d fix: Issues column shows only NW > GW weight errors
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:37:01 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 2c08a6593a feat: add Issues column to Pricing view showing unit/weight errors per row
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:34:10 +02:00
Christian Vidal Wolf 10a2b3effd fix: include empty values in missing data view filters 2026-04-22 14:31:41 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 4460213da3 feat: detect NW > GW weight errors in Pricing Units tab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:31:16 +02:00
Christian Vidal Wolf a1b046feb1 feat: include empty classification in missing data view and improve pricing view column detection 2026-04-22 14:29:38 +02:00
Christian Vidal Wolf 9d169a0bdd Add pagination to Pricing tab
Load data in batches of 100 products per page with navigation controls
2026-04-21 11:25:20 +02:00
Christian Vidal Wolf 99ad4f7d25 feat: add sorting to Pricing Units table 2026-04-21 09:32:15 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 f15871f89f feat: add TYPE column to Pricing view with autocomplete suggestions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:24:02 +02:00
Christian Vidal Wolf 77819f5d3a fix: restore row status on undo and improve change detection 2026-04-21 09:05:14 +02:00
Christian Vidal Wolf 2b994e3c86 feat: implement resizable columns in Pricing Units tab 2026-04-21 09:03:01 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 9b861f1c19 fix: remove yellow highlight when modification is reverted to original value
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 09:01:17 +02:00
Christian Vidal Wolf 6a864a4c35 Fix column resize - make handle more visible and add pointer-events 2026-04-21 08:56:37 +02:00
Christian Vidal Wolf 3462ac9991 feat: update Dropbox source URL and implement smart dirty-checking for row highlight 2026-04-21 08:55:50 +02:00
Christian Vidal Wolf 964bb62838 Fix column resizing in Pricing table
Improve resize logic with proper mouse delta calculation
2026-04-21 08:49:02 +02:00
Christian Vidal Wolf b4cb7cbe57 Add column resizing in Pricing table
Drag column borders to resize width like Excel
2026-04-21 08:40:54 +02:00
Christian Vidal Wolf d8c65a22f9 fix: improve contrast between validated and pending states and fix instant background removal 2026-04-21 08:36:06 +02:00
Christian Vidal Wolf d8f3dfbed3 Enable multi-select in Pricing search dropdown
Search dropdown now allows selecting multiple products with checkboxes and Apply button
2026-04-21 08:30:14 +02:00
Christian Vidal Wolf ed9e8ba3ab Fix Refresh button to bypass browser cache
Use cache: 'no-store' and cache buster to force fresh data load
2026-04-20 20:39:23 +02:00
Christian Vidal Wolf 03b98c9aed Add tooltip preview on hover for notes in Pricing tab
Show note content in popup when hovering over the notes icon
2026-04-20 20:35:46 +02:00
Christian Vidal Wolf 9878efb2ca feat: enhance PricingView search bar with a searchable dropdown list 2026-04-20 20:34:28 +02:00
Christian Vidal Wolf ecf1c7831c Add Hard Refresh button to TopBar
Button triggers reload of all data from Dropbox source
2026-04-20 20:30:21 +02:00
Christian Vidal Wolf 369e37c8f5 Filter out old launch dates before Dec 2025 in Missing Data tabs 2026-04-20 20:24:39 +02:00
Christian Vidal Wolf 66a42ad1a0 Add Launch Date Inconsistencies and Upcoming Launch Date tabs
- Add Launch Date Inconsistencies tab: shows when Ready to Order > Launch Date
- Add Upcoming Launch Date tab: alerts when launch is within 180 days (yellow) or 120 days (red)
- Show days until launch countdown
2026-04-20 18:48:30 +02:00
Christian Vidal Wolf db696d2816 feat: redesign PricingView with manual validation check and notes, and add Matrix columns 2026-04-20 18:46:50 +02:00
Christian Vidal Wolf 111a44ae82 Add close X button to filter popover
Add X button in top-right corner to close filter dropdown
2026-04-20 17:15:41 +02:00
Christian Vidal Wolf b76b64b044 Add Excel-like filter conditions to column filters
Add Condition tab with: equals, not equals, contains, starts with, ends with, greater than, less than, between
2026-04-20 17:05:24 +02:00
Christian Vidal Wolf af2ed8a037 Add dynamic column filters to all pricing columns
Add filter dropdowns to SRP, UVP, and container columns in Pricing tab
2026-04-20 17:01:47 +02:00
Christian Vidal Wolf 5f96272182 Add column filters for Units/Outer and Outer W/L/H in Pricing tab
Add dropdown filters to filter by specific values in Units/Outer, Outer W, Outer L, Outer H columns
2026-04-20 16:57:02 +02:00
Christian Vidal Wolf 67a0466627 Refine AI button labeling logic: only use 'Translate' for same-field cross-language content 2026-04-20 16:53:21 +02:00
Christian Vidal Wolf 4c39c9ec53 Add Outer W, L, H columns to Pricing tab
Display outer box dimensions next to Units/Outer column in pricing view
2026-04-20 16:51:54 +02:00
Christian Vidal Wolf dab6ccf723 Update short description AI prompts with character limits and improved translation logic 2026-04-20 16:50:21 +02:00
Christian Vidal Wolf 23074ed42f Fix ColumnFilterPopover single-click and drag-selection interaction 2026-04-20 16:42:07 +02:00
Christian Vidal Wolf 1d9e77038a Add Details DE/EN to description columns filter
Include DETAILS columns in description filter for Present/Missing filtering and improve empty value handling
2026-04-20 16:37:33 +02:00
Christian Vidal Wolf b6b60e7dfb Hide stats header in TopBar for non-descriptions tabs
Only show Total, Missing DE/EN Long/Short, Fully Complete header when viewing Product Descriptions tab
2026-04-20 16:35:40 +02:00
Christian Vidal Wolf 86c3ace6f8 Add dropdown selector for individual row field changes in Dimensions tab
- Added dropdown that shows unique values from the group (outer/units/moq)
- Click icon to open dropdown and select value to change single row
- Icon turns green on hover (emerald color)
- Replaces sync all icon with ChevronDown dropdown icon
2026-04-20 11:46:55 +02:00
Christian Vidal Wolf f358b61229 fix: resolve ReferenceError in DimensionsView by moving useEffect after variable declaration 2026-04-16 12:03:39 +02:00
Christian Vidal Wolf cbfd5f573d feat: implement excel-style drag-to-select and shift-click in Dimensions and filters 2026-04-16 12:01:59 +02:00
Christian Vidal Wolf 19ca75c1b9 Fix drag selection with refs 2026-04-16 11:55:30 +02:00
Christian Vidal Wolf dc46f91e7b Add drag selection to filter dropdowns 2026-04-16 11:52:57 +02:00
Christian Vidal Wolf 3369fc90ed fix: force showPicker for date inputs and add dark mode support 2026-04-14 18:58:07 +02:00
Christian Vidal Wolf 7c7258d87c feat: implement native date pickers and YYYY-MM-DD format in Missing Data view 2026-04-14 18:55:23 +02:00
Christian Vidal Wolf d4512aa4e6 Improve date column detection in Missing Data filters 2026-04-14 17:17:43 +02:00
Christian Vidal Wolf a1402316e0 Add date range filter with calendar picker for Launch Date and Ready to Order 2026-04-14 17:13:47 +02:00
Christian Vidal Wolf 78df66be24 Add date picker for launch and ready to order fields 2026-04-14 17:09:12 +02:00
Christian Vidal Wolf f4f69633ce Add classification dropdown in Missing Data edit panel 2026-04-14 17:06:06 +02:00
Christian Vidal Wolf 4771a9b160 Fix: Improve AI description completeness and increase Gemini token limit to 4096 2026-04-14 11:23:49 +02:00
Christian Vidal Wolf 4533a98ddf Add multi-select filters to Missing Data view using ColumnFilterPopover 2026-04-12 22:27:04 +02:00
Christian Vidal Wolf 31921666ad Remove missingClass tab, keep only missingLaunch 2026-04-12 22:23:43 +02:00
Christian Vidal Wolf a5d4305006 Fix columnUniqueValues dependency to use columns before it's defined 2026-04-12 18:38:47 +02:00
Christian Vidal Wolf dce4588d0c Add dropdown filters to Missing Data view with unique values 2026-04-12 18:36:19 +02:00
68 changed files with 12162 additions and 982 deletions
+142
View File
@@ -0,0 +1,142 @@
---
name: find-skills
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
---
# Find Skills
This skill helps you discover and install skills from the open agent skills ecosystem.
## When to Use This Skill
Use this skill when the user:
- Asks "how do I do X" where X might be a common task with an existing skill
- Says "find a skill for X" or "is there a skill for X"
- Asks "can you do X" where X is a specialized capability
- Expresses interest in extending agent capabilities
- Wants to search for tools, templates, or workflows
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
## What is the Skills CLI?
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
**Key commands:**
- `npx skills find [query]` - Search for skills interactively or by keyword
- `npx skills add <package>` - Install a skill from GitHub or other sources
- `npx skills check` - Check for skill updates
- `npx skills update` - Update all installed skills
**Browse skills at:** https://skills.sh/
## How to Help Users Find Skills
### Step 1: Understand What They Need
When a user asks for help with something, identify:
1. The domain (e.g., React, testing, design, deployment)
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
3. Whether this is a common enough task that a skill likely exists
### Step 2: Check the Leaderboard First
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
For example, top skills for web development include:
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
### Step 3: Search for Skills
If the leaderboard doesn't cover the user's need, run the find command:
```bash
npx skills find [query]
```
For example:
- User asks "how do I make my React app faster?" → `npx skills find react performance`
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
- User asks "I need to create a changelog" → `npx skills find changelog`
### Step 4: Verify Quality Before Recommending
**Do not recommend a skill based solely on search results.** Always verify:
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
### Step 5: Present Options to the User
When you find relevant skills, present them to the user with:
1. The skill name and what it does
2. The install count and source
3. The install command they can run
4. A link to learn more at skills.sh
Example response:
```
I found a skill that might help! The "react-best-practices" skill provides
React and Next.js performance optimization guidelines from Vercel Engineering.
(185K installs)
To install it:
npx skills add vercel-labs/agent-skills@react-best-practices
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
```
### Step 6: Offer to Install
If the user wants to proceed, you can install the skill for them:
```bash
npx skills add <owner/repo@skill> -g -y
```
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
## Common Skill Categories
When searching, consider these common categories:
| Category | Example Queries |
| --------------- | ---------------------------------------- |
| Web Development | react, nextjs, typescript, css, tailwind |
| Testing | testing, jest, playwright, e2e |
| DevOps | deploy, docker, kubernetes, ci-cd |
| Documentation | docs, readme, changelog, api-docs |
| Code Quality | review, lint, refactor, best-practices |
| Design | ui, ux, design-system, accessibility |
| Productivity | workflow, automation, git |
## Tips for Effective Searches
1. **Use specific keywords**: "react testing" is better than just "testing"
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
## When No Skills Are Found
If no relevant skills exist:
1. Acknowledge that no existing skill was found
2. Offer to help with the task directly using your general capabilities
3. Suggest the user could create their own skill with `npx skills init`
Example:
```
I searched for skills related to "xyz" but didn't find any matches.
I can still help you with this task directly! Would you like me to proceed?
If this is something you do often, you could create your own skill:
npx skills init my-xyz-skill
```
+289
View File
@@ -0,0 +1,289 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" pre-bash'",
"timeout": 5000
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" pre-edit'",
"timeout": 5000
}
]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-edit'",
"timeout": 10000
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-bash'",
"timeout": 5000
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" route'",
"timeout": 10000
}
]
}
],
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-restore'",
"timeout": 15000
},
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/auto-memory-hook.mjs\" import'",
"timeout": 8000
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
"timeout": 10000
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/auto-memory-hook.mjs\" sync'",
"timeout": 10000
}
]
}
],
"PreCompact": [
{
"matcher": "manual",
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" compact-manual'"
},
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
"timeout": 5000
}
]
},
{
"matcher": "auto",
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" compact-auto'"
},
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" session-end'",
"timeout": 6000
}
]
}
],
"SubagentStart": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" status'",
"timeout": 3000
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" post-task'",
"timeout": 5000
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/hook-handler.cjs\" notify'",
"timeout": 3000
}
]
}
]
},
"statusLine": {
"type": "command",
"command": "sh -c 'exec node \"${CLAUDE_PROJECT_DIR:-.}/.claude/helpers/statusline.cjs\"'"
},
"permissions": {
"allow": [
"Bash(npx @claude-flow*)",
"Bash(npx claude-flow*)",
"Bash(node .claude/*)",
"mcp__claude-flow__:*"
],
"deny": [
"Read(./.env)",
"Read(./.env.*)"
]
},
"attribution": {
"commit": "Co-Authored-By: claude-flow <ruv@ruv.net>",
"pr": "🤖 Generated with [claude-flow](https://github.com/ruvnet/claude-flow)"
},
"env": {
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1",
"CLAUDE_FLOW_V3_ENABLED": "true",
"CLAUDE_FLOW_HOOKS_ENABLED": "true"
},
"claudeFlow": {
"version": "3.0.0",
"enabled": true,
"platform": {
"os": "darwin",
"arch": "arm64",
"shell": "zsh"
},
"modelPreferences": {
"default": "claude-opus-4-6",
"routing": "claude-haiku-4-5-20251001"
},
"agentTeams": {
"enabled": true,
"teammateMode": "auto",
"taskListEnabled": true,
"mailboxEnabled": true,
"coordination": {
"autoAssignOnIdle": true,
"trainPatternsOnComplete": true,
"notifyLeadOnComplete": true,
"sharedMemoryNamespace": "agent-teams"
},
"hooks": {
"teammateIdle": {
"enabled": true,
"autoAssign": true,
"checkTaskList": true
},
"taskCompleted": {
"enabled": true,
"trainPatterns": true,
"notifyLead": true
}
}
},
"swarm": {
"topology": "hierarchical-mesh",
"maxAgents": 15
},
"memory": {
"backend": "hybrid",
"enableHNSW": true,
"learningBridge": {
"enabled": true
},
"memoryGraph": {
"enabled": true
},
"agentScopes": {
"enabled": true
}
},
"neural": {
"enabled": true
},
"daemon": {
"autoStart": false,
"workers": [
"map",
"audit",
"optimize"
],
"schedules": {
"audit": {
"interval": "4h",
"priority": "critical"
},
"optimize": {
"interval": "2h",
"priority": "high"
}
}
},
"learning": {
"enabled": true,
"autoTrain": true,
"patterns": [
"coordination",
"optimization",
"prediction"
],
"retention": {
"shortTerm": "24h",
"longTerm": "30d"
}
},
"adr": {
"autoGenerate": true,
"directory": "/docs/adr",
"template": "madr"
},
"ddd": {
"trackDomains": true,
"validateBoundedContexts": true,
"directory": "/docs/ddd"
},
"security": {
"autoScan": true,
"scanOnEdit": true,
"cveCheck": true,
"threatModel": true
}
}
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/find-skills
+16
View File
@@ -3,6 +3,22 @@
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# Business Central OAuth client credentials
BC_TENANT_ID="MY_BC_TENANT_ID"
BC_CLIENT_ID="MY_BC_CLIENT_ID"
BC_CLIENT_SECRET="MY_BC_CLIENT_SECRET"
BC_COMPANY_ID="MY_BC_COMPANY_ID"
BC_WRITE_METHOD="PATCH"
BC_WRITE_URL_TEMPLATE="{{itemsUrl}}('{{itemNo}}')"
BC_WRITE_BODY_TEMPLATE='{"cpnpNo":"{{cpnpNo}}"}'
BC_ITEMS_WRITE_METHOD="PATCH"
BC_ITEMS_WRITE_URL_TEMPLATE="{{itemsUrl}}('{{itemNo}}')"
BC_ITEMS_WRITE_BODY_TEMPLATE=''
BC_UOM_WRITE_METHOD="PATCH"
BC_UOM_CODE="OUTER"
BC_UOM_WRITE_URL_TEMPLATE="{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')"
BC_UOM_WRITE_BODY_TEMPLATE=''
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
+13
View File
@@ -8,3 +8,16 @@ coverage/
!.env.example
.vercel
.env*.local
# RuFlo / Claude-Flow
.claude-flow/
.claude/agents/
.claude/helpers/
.claude/commands/
.claude/skills/
.agents/
.swarm/
agentdb.rvf*
ruvector.db
claude-flow.config.json
scratch/
+22
View File
@@ -0,0 +1,22 @@
{
"mcpServers": {
"claude-flow": {
"command": "npx",
"args": [
"-y",
"@claude-flow/cli@latest",
"mcp",
"start"
],
"env": {
"npm_config_update_notifier": "false",
"CLAUDE_FLOW_MODE": "v3",
"CLAUDE_FLOW_HOOKS_ENABLED": "true",
"CLAUDE_FLOW_TOPOLOGY": "hierarchical-mesh",
"CLAUDE_FLOW_MAX_AGENTS": "15",
"CLAUDE_FLOW_MEMORY_BACKEND": "hybrid"
},
"autoStart": false
}
}
}
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/find-skills
+7
View File
@@ -0,0 +1,7 @@
node_modules
dist
.git
agentdb.rvf
agentdb.rvf.lock
ruvector.db
scratch
+1
View File
@@ -0,0 +1 @@
{}
+11
View File
@@ -1,5 +1,16 @@
# AGENTS.md - Developer Guidelines for Craze-Data-check
## Ruflo Standard Operating Procedure (SOP)
**MANDATORY: Follow these steps for every user instruction.**
1. **Retrieve (Semantic Memory)**: Before starting, run `ruflo memory search -q "<task description>"` to find relevant historical patterns or previous solutions.
2. **Verify (Security)**: Use `ruflo security defend -i "<user instruction>"` to ensure the instruction is safe and follows security protocols.
3. **Execute (Agent Swarm)**: For complex tasks (refactors, multi-file changes), consider spawning specialized agents with `ruflo agent spawn -t <type>`.
4. **Version Control**: Use `agentic-jujutsu` for managing concurrent changes or if a lock-free approach is needed.
5. **Record (Learning)**: After completion, record the outcome with `ruflo memory store --key "<task_id>" --value "<outcome_details>"` to improve future performance.
---
## Build, Lint, and Test Commands
### Development
+244
View File
@@ -0,0 +1,244 @@
# Claude Code Configuration - RuFlo V3
## Behavioral Rules (Always Enforced)
- Do what has been asked; nothing more, nothing less
- NEVER create files unless they're absolutely necessary for achieving your goal
- ALWAYS prefer editing an existing file to creating a new one
- NEVER proactively create documentation files (*.md) or README files unless explicitly requested
- NEVER save working files, text/mds, or tests to the root folder
- Never continuously check status after spawning a swarm — wait for results
- ALWAYS read a file before editing it
- NEVER commit secrets, credentials, or .env files
## File Organization
- NEVER save to root folder — use the directories below
- Use `/src` for source code files
- Use `/tests` for test files
- Use `/docs` for documentation and markdown files
- Use `/config` for configuration files
- Use `/scripts` for utility scripts
- Use `/examples` for example code
## Project Architecture
- Follow Domain-Driven Design with bounded contexts
- Keep files under 500 lines
- Use typed interfaces for all public APIs
- Prefer TDD London School (mock-first) for new code
- Use event sourcing for state changes
- Ensure input validation at system boundaries
### Project Config
- **Topology**: hierarchical-mesh
- **Max Agents**: 15
- **Memory**: hybrid
- **HNSW**: Enabled
- **Neural**: Enabled
## Build & Test
```bash
# Build
npm run build
# Test
npm test
# Lint
npm run lint
```
- ALWAYS run tests after making code changes
- ALWAYS verify build succeeds before committing
## Security Rules
- NEVER hardcode API keys, secrets, or credentials in source files
- NEVER commit .env files or any file containing secrets
- Always validate user input at system boundaries
- Always sanitize file paths to prevent directory traversal
- Run `npx @claude-flow/cli@latest security scan` after security-related changes
## Concurrency: 1 MESSAGE = ALL RELATED OPERATIONS
- All operations MUST be concurrent/parallel in a single message
- Use Claude Code's Agent tool for spawning agents, not just MCP
- ALWAYS spawn ALL agents in ONE message with full instructions via Agent tool
- ALWAYS batch ALL file reads/writes/edits in ONE message
- ALWAYS batch ALL Bash commands in ONE message
## Swarm Orchestration
- MUST initialize the swarm using CLI tools when starting complex tasks
- MUST spawn concurrent agents using Claude Code's Agent tool
- Never use CLI tools alone for execution — Agent tool agents do the actual work
- MUST call CLI tools AND Agent tool in ONE message for complex work
### 3-Tier Model Routing (ADR-026)
| Tier | Handler | Latency | Cost | Use Cases |
|------|---------|---------|------|-----------|
| **1** | Agent Booster (WASM) | <1ms | $0 | Simple transforms (var→const, add types) — Skip LLM |
| **2** | Haiku | ~500ms | $0.0002 | Simple tasks, low complexity (<30%) |
| **3** | Sonnet/Opus | 2-5s | $0.003-0.015 | Complex reasoning, architecture, security (>30%) |
- For Tier 1 simple transforms, use Edit tool directly — no LLM agent needed
## Swarm Configuration & Anti-Drift
- ALWAYS use hierarchical topology for coding swarms
- Keep maxAgents at 6-8 for tight coordination
- Use specialized strategy for clear role boundaries
- Use `raft` consensus for hive-mind (leader maintains authoritative state)
- Run frequent checkpoints via `post-task` hooks
- Keep shared memory namespace for all agents
```bash
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized
```
## Swarm Execution Rules
- ALWAYS use `run_in_background: true` for all Agent tool calls
- ALWAYS put ALL Agent calls in ONE message for parallel execution
- After spawning, STOP — do NOT add more tool calls or check status
- Never poll agent status repeatedly — trust agents to return
- When agent results arrive, review ALL results before proceeding
## V3 CLI Commands
### Core Commands
| Command | Subcommands | Description |
|---------|-------------|-------------|
| `init` | 4 | Project initialization |
| `agent` | 8 | Agent lifecycle management |
| `swarm` | 6 | Multi-agent swarm coordination |
| `memory` | 11 | AgentDB memory with HNSW search |
| `task` | 6 | Task creation and lifecycle |
| `session` | 7 | Session state management |
| `hooks` | 17 | Self-learning hooks + 12 workers |
| `hive-mind` | 6 | Byzantine fault-tolerant consensus |
### Quick CLI Examples
```bash
npx @claude-flow/cli@latest init --wizard
npx @claude-flow/cli@latest agent spawn -t coder --name my-coder
npx @claude-flow/cli@latest swarm init --v3-mode
npx @claude-flow/cli@latest memory search --query "authentication patterns"
npx @claude-flow/cli@latest doctor --fix
```
## Available Agents (16 Roles + Custom)
### Core Development
`coder`, `reviewer`, `tester`, `planner`, `researcher`
### Specialized
`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer`
### Coordination
`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`
### GitHub & Repository
`pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`
Any string can be used as a custom agent type — these are the typed roles with specialized behavior.
## Memory & Vector Search
### MCP Tools (use via ToolSearch to discover)
| Tool | Description |
|------|-------------|
| `memory_store` | Store value with ONNX 384-dim vector embedding |
| `memory_search` | Semantic vector search by query |
| `memory_retrieve` | Get entry by key |
| `memory_list` | List entries in namespace |
| `memory_delete` | Delete entry |
| `memory_import_claude` | Import Claude Code memories into AgentDB (allProjects=true for all) |
| `memory_search_unified` | Search across ALL namespaces (Claude + AgentDB + patterns) |
| `memory_bridge_status` | Show bridge health, vectors, SONA, intelligence |
### CLI Commands
```bash
# Store with vector embedding
npx @claude-flow/cli@latest memory store --key "pattern-auth" --value "JWT with refresh" --namespace patterns
# Semantic search
npx @claude-flow/cli@latest memory search --query "authentication patterns"
# Import all Claude Code memories into AgentDB
node .claude/helpers/auto-memory-hook.mjs import-all
```
### Claude Code ↔ AgentDB Bridge
Claude Code auto-memory files (`~/.claude/projects/*/memory/*.md`) are automatically imported into AgentDB with ONNX vector embeddings on session start. Use `memory_search_unified` to search across both stores.
## Key MCP Tools (314 available — use ToolSearch to discover)
### Most Used Tools
| Category | Tools | What They Do |
|----------|-------|-------------|
| **Memory** | `memory_store`, `memory_search`, `memory_search_unified` | Store/search with ONNX vector embeddings |
| **Claude Bridge** | `memory_import_claude`, `memory_bridge_status` | Import Claude memories into AgentDB |
| **Swarm** | `swarm_init`, `swarm_status`, `swarm_health` | Multi-agent coordination |
| **Agents** | `agent_spawn`, `agent_list`, `agent_status` | Agent lifecycle |
| **Hive-Mind** | `hive-mind_init`, `hive-mind_spawn`, `hive-mind_consensus` | Byzantine/Raft consensus |
| **Hooks** | `hooks_route`, `hooks_session-start`, `hooks_post-task` | Task routing + learning |
| **Workers** | `hooks_worker-list`, `hooks_worker-dispatch` | 12 background workers |
| **Security** | `aidefence_scan`, `aidefence_is_safe` | Prompt injection detection |
| **Intelligence** | `hooks_intelligence`, `neural_status` | Pattern learning + SONA |
### Swarm Capabilities
- **Topologies**: hierarchical (anti-drift), mesh, ring, star, adaptive
- **Consensus**: Raft (leader-based), Byzantine (PBFT), Gossip (eventual)
- **Hive-Mind**: Queen-led coordination with spawn, broadcast, consensus voting, shared memory
- **12 Background Workers**: audit, optimize, testgaps, map, deepdive, document, refactor, benchmark, ultralearn, consolidate, predict, preload
### Memory Capabilities
- **ONNX Embeddings**: all-MiniLM-L6-v2, 384 dimensions — real neural vectors
- **DiskANN**: SSD-friendly vector search (8,000x faster insert than HNSW, perfect recall at 1K)
- **sql.js**: Cross-platform SQLite (WASM, no native compilation)
- **Claude Code Bridge**: Auto-imports MEMORY.md files into AgentDB on session start
- **Unified Search**: `memory_search_unified` searches Claude memories + AgentDB + patterns
- **SONA Learning**: Trajectory recording → pattern extraction → file persistence
### How to Discover Tools
Use ToolSearch to find specific tools:
```
ToolSearch("memory search") → memory_store, memory_search, memory_search_unified
ToolSearch("swarm") → swarm_init, swarm_status, swarm_health, swarm_shutdown
ToolSearch("hive consensus") → hive-mind_consensus, hive-mind_status
ToolSearch("+aidefence") → aidefence_scan, aidefence_is_safe, aidefence_has_pii
```
## Quick Setup
```bash
claude mcp add claude-flow -- npx -y @claude-flow/cli@latest
npx @claude-flow/cli@latest daemon start
npx @claude-flow/cli@latest doctor --fix
```
## Claude Code vs MCP Tools
- **Claude Code Agent tool** handles execution: agents, file ops, code generation, git
- **MCP tools** (via ToolSearch) handle coordination: swarm, memory, hooks, routing, hive-mind
- **CLI commands** (via Bash) are the same tools with terminal output
- Use `ToolSearch("keyword")` to discover available MCP tools
## Support
- Documentation: https://github.com/ruvnet/ruflo
- Issues: https://github.com/ruvnet/ruflo/issues
+8 -1
View File
@@ -16,5 +16,12 @@ View your app in AI Studio: https://ai.studio/apps/bac9908e-4996-4e4c-8ec7-3add5
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
3. To use the Business Central download button, set:
`BC_TENANT_ID`, `BC_CLIENT_ID`, `BC_CLIENT_SECRET`, `BC_COMPANY_ID`
4. If your Business Central writable endpoint is not the default `PATCH`, also set:
`BC_WRITE_METHOD`, `BC_WRITE_URL_TEMPLATE`, `BC_WRITE_BODY_TEMPLATE`
5. For the safer BC sync preview/apply flow, you can also set:
`BC_ITEMS_WRITE_METHOD`, `BC_ITEMS_WRITE_URL_TEMPLATE`, `BC_ITEMS_WRITE_BODY_TEMPLATE`,
`BC_UOM_CODE`, `BC_UOM_WRITE_METHOD`, `BC_UOM_WRITE_URL_TEMPLATE`, `BC_UOM_WRITE_BODY_TEMPLATE`
6. Run the app:
`npm run dev`
+38
View File
@@ -0,0 +1,38 @@
const LOCALHOST_ORIGIN_PREFIXES = ['http://localhost:', 'http://127.0.0.1:'];
export function isAllowedOrigin(origin) {
if (!origin) return false;
if (LOCALHOST_ORIGIN_PREFIXES.some(prefix => origin.startsWith(prefix))) {
return true;
}
try {
const url = new URL(origin);
if (url.hostname === 'craze-data-check.vercel.app') return true;
if (url.hostname.endsWith('.vercel.app')) return true;
if (process.env.APP_URL) {
const appUrl = new URL(process.env.APP_URL);
if (url.hostname === appUrl.hostname) return true;
}
if (process.env.VERCEL_URL) {
const vercelHost = process.env.VERCEL_URL.replace(/^https?:\/\//, '');
if (url.hostname === vercelHost) return true;
}
} catch {
return false;
}
return false;
}
export function applyCors(req, res, methods) {
const origin = req.headers.origin;
if (isAllowedOrigin(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
}
res.setHeader('Access-Control-Allow-Methods', methods);
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, apikey');
res.setHeader('Access-Control-Max-Age', '86400');
res.setHeader('Vary', 'Origin');
}
+148
View File
@@ -0,0 +1,148 @@
const SUPABASE_URL = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET;
const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
const BACKUP_SECRET = process.env.BACKUP_SECRET;
const BACKUP_CRON_ENABLED = process.env.BACKUP_CRON_ENABLED === 'true';
const DROPBOX_BACKUP_FOLDER = '/CrazeBackups';
const MAX_BACKUP_AGE_DAYS = 7;
async function getDropboxToken() {
const res = await fetch('https://api.dropboxapi.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: DROPBOX_REFRESH_TOKEN,
client_id: DROPBOX_APP_KEY,
client_secret: DROPBOX_APP_SECRET,
}),
});
const data = await res.json();
if (!data.access_token) throw new Error('Dropbox token failed: ' + JSON.stringify(data));
return data.access_token;
}
async function fetchSupabaseTable(table) {
const PAGE_SIZE = 1000;
const rows = [];
let offset = 0;
for (let page = 0; page < 50; page++) {
const res = await fetch(
`${SUPABASE_URL}/rest/v1/${table}?select=*&limit=${PAGE_SIZE}&offset=${offset}`,
{ headers: { apikey: SUPABASE_SERVICE_KEY, Authorization: `Bearer ${SUPABASE_SERVICE_KEY}` } }
);
if (!res.ok) {
const txt = await res.text();
throw new Error(`Supabase ${table} fetch failed (${res.status}): ${txt}`);
}
const batch = await res.json();
rows.push(...batch);
if (batch.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
return rows;
}
async function uploadToDropbox(token, path, content) {
const res = await fetch('https://content.dropboxapi.com/2/files/upload', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/octet-stream',
'Dropbox-API-Arg': JSON.stringify({ path, mode: 'overwrite', autorename: false }),
},
body: content,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Dropbox upload failed (${res.status}): ${txt}`);
}
return res.json();
}
async function deleteOldBackups(token) {
const listRes = await fetch('https://api.dropboxapi.com/2/files/list_folder', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: DROPBOX_BACKUP_FOLDER, limit: 200 }),
});
if (!listRes.ok) return; // folder may not exist yet — skip
const list = await listRes.json();
const cutoff = Date.now() - MAX_BACKUP_AGE_DAYS * 24 * 60 * 60 * 1000;
for (const entry of list.entries || []) {
if (entry['.tag'] !== 'file') continue;
const modified = new Date(entry.server_modified).getTime();
if (modified < cutoff) {
await fetch('https://api.dropboxapi.com/2/files/delete_v2', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ path: entry.path_lower }),
});
}
}
}
export default async function handler(req, res) {
// Backup cron disabled by default to prevent noisy scheduled failures.
if (!BACKUP_CRON_ENABLED) {
return res.status(200).json({
success: true,
skipped: true,
reason: 'backup cron disabled',
});
}
const authHeader = req.headers['authorization'];
const isCron = !req.headers.origin;
const hasSecret = BACKUP_SECRET && authHeader === `Bearer ${BACKUP_SECRET}`;
if (!isCron && !hasSecret) {
return res.status(403).json({ error: 'Forbidden' });
}
if (req.method !== 'GET' && req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const [syncedRows, history] = await Promise.all([
fetchSupabaseTable('products'),
fetchSupabaseTable('products_history'),
]);
const now = new Date();
const timestamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
const filename = `backup_${timestamp}.json`;
const path = `${DROPBOX_BACKUP_FOLDER}/${filename}`;
const payload = JSON.stringify({
created_at: now.toISOString(),
synced_rows_count: syncedRows.length,
history_count: history.length,
synced_rows: syncedRows,
history,
});
const token = await getDropboxToken();
await Promise.all([
uploadToDropbox(token, path, payload),
deleteOldBackups(token),
]);
console.log(`[backup] OK — ${filename} | rows: ${syncedRows.length} | history: ${history.length}`);
return res.json({
success: true,
filename,
synced_rows_count: syncedRows.length,
history_count: history.length,
});
} catch (err) {
console.error('[backup] error:', err.message);
return res.status(500).json({ success: false, error: err.message });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { applyCors, isAllowedOrigin } from './_cors.js';
import * as XLSX from 'xlsx';
import { getBcConfig, getBCToken, fetchAllItems, buildWorkbook } from '../bc-runtime.js';
function setCors(req, res) {
applyCors(req, res, 'GET, OPTIONS');
}
export default async function handler(req, res) {
setCors(req, res);
if (req.method === 'OPTIONS') return res.status(204).end();
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: 'Forbidden' });
}
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const config = getBcConfig();
const token = await getBCToken(config);
const items = await fetchAllItems(config, token);
if (req.query?.format === 'json') {
return res.json({ success: true, count: items.length, items });
}
const workbook = buildWorkbook(items);
const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
const dateStr = new Date().toISOString().split('T')[0];
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="BusinessCentral_Items_${dateStr}.xlsx"`);
res.setHeader('Cache-Control', 'no-store');
return res.status(200).send(buffer);
} catch (err) {
console.error('[bc-export] error:', err?.message || err);
return res.status(500).json({ success: false, error: err?.message || String(err) });
}
}
+37
View File
@@ -0,0 +1,37 @@
import { applyCors, isAllowedOrigin } from './_cors.js';
import { getBcConfig, getBCToken, findItem, patchItemCpnpNo } from '../bc-runtime.js';
function setCors(req, res) {
applyCors(req, res, 'POST, OPTIONS');
}
export default async function handler(req, res) {
setCors(req, res);
if (req.method === 'OPTIONS') return res.status(204).end();
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: 'Forbidden' });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { articleNo, cpnpNo } = req.body || {};
if (!articleNo || cpnpNo === undefined) {
return res.status(400).json({ error: 'Missing articleNo or cpnpNo' });
}
try {
const config = getBcConfig();
const token = await getBCToken(config);
const item = await findItem(config, token, articleNo);
await patchItemCpnpNo(config, token, item, String(cpnpNo));
return res.json({ success: true, articleNo, cpnpNo });
} catch (err) {
console.error('[bc-proxy] error:', err.message);
return res.status(500).json({ success: false, error: err.message });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { applyCors, isAllowedOrigin } from './_cors.js';
import { getBcConfig, getBCToken } from '../bc-runtime.js';
import { applyBusinessCentralCpnp, applyBusinessCentralSync } from '../bc-sync-runtime.js';
function setCors(req, res) {
applyCors(req, res, 'POST, OPTIONS');
}
export default async function handler(req, res) {
setCors(req, res);
if (req.method === 'OPTIONS') return res.status(204).end();
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: 'Forbidden' });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { headers, row, articleNo, cpnpNo, previewToken } = req.body || {};
try {
const config = getBcConfig();
const token = await getBCToken(config);
if (Array.isArray(headers) && Array.isArray(row)) {
const result = await applyBusinessCentralSync(config, token, headers, row, previewToken);
return res.json({ success: true, ...result });
}
if (articleNo && cpnpNo !== undefined) {
const result = await applyBusinessCentralCpnp(config, token, articleNo, cpnpNo, previewToken);
return res.json(result);
}
return res.status(400).json({ error: 'Missing headers/row or articleNo/cpnpNo' });
} catch (err) {
console.error('[bc-sync-apply] error:', err.message);
return res.status(err.statusCode || 500).json({ success: false, error: err.message });
}
}
+44
View File
@@ -0,0 +1,44 @@
import { applyCors, isAllowedOrigin } from './_cors.js';
import { getBcConfig, getBCToken } from '../bc-runtime.js';
import { previewBusinessCentralCpnp, previewBusinessCentralSync } from '../bc-sync-runtime.js';
function setCors(req, res) {
applyCors(req, res, 'POST, OPTIONS');
}
export default async function handler(req, res) {
setCors(req, res);
if (req.method === 'OPTIONS') return res.status(204).end();
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: 'Forbidden' });
}
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { headers, row, articleNo, cpnpNo } = req.body || {};
try {
const config = getBcConfig();
const token = await getBCToken(config);
if (Array.isArray(headers) && Array.isArray(row)) {
const preview = await previewBusinessCentralSync(config, token, headers, row);
return res.json({ success: true, ...preview });
}
if (articleNo && cpnpNo !== undefined) {
const preview = await previewBusinessCentralCpnp(config, token, articleNo, cpnpNo);
return res.json({ success: true, ...preview });
}
return res.status(400).json({ error: 'Missing headers/row or articleNo/cpnpNo' });
} catch (err) {
console.error('[bc-sync-preview] error:', err.message);
return res.status(500).json({ success: false, error: err.message });
}
}
+10
View File
@@ -0,0 +1,10 @@
export default async function handler(req, res) {
return res.json({
env: {
SUPABASE_URL: !!process.env.SUPABASE_URL || !!process.env.VITE_SUPABASE_URL,
SUPABASE_SERVICE_KEY: !!process.env.SUPABASE_SERVICE_KEY || !!process.env.SUPABASE_SERVICE_ROLE_KEY,
SUPABASE_ANON_KEY: !!process.env.VITE_SUPABASE_ANON_KEY || !!process.env.SUPABASE_ANON_KEY
},
url: process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || 'not set'
});
}
+100 -35
View File
@@ -1,16 +1,47 @@
const DROPBOX_APP_KEY = process.env.DROPBOX_APP_KEY;
const DROPBOX_APP_SECRET = process.env.DROPBOX_APP_SECRET;
const DROPBOX_REFRESH_TOKEN = process.env.DROPBOX_REFRESH_TOKEN;
import { applyCors, isAllowedOrigin } from './_cors.js';
const DEFAULT_DROPBOX_SHARED_URL = 'https://www.dropbox.com/scl/fi/usa8me7ywgylrij2bt6hj/Data-Matrix.xlsx?rlkey=tsec8csrhye54u1fdvk15ped1&st=qbxxs4cn&dl=0';
function getDropboxConfig() {
return {
appKey: process.env.DROPBOX_APP_KEY,
appSecret: process.env.DROPBOX_APP_SECRET,
refreshToken: process.env.DROPBOX_REFRESH_TOKEN,
sharedUrl: process.env.DROPBOX_SHARED_URL || DEFAULT_DROPBOX_SHARED_URL,
};
}
function normalizeDropboxSharedUrl(sharedUrl) {
const url = new URL(sharedUrl);
url.searchParams.delete('dl');
url.searchParams.delete('raw');
url.searchParams.delete('st');
return url.toString();
}
function buildDropboxDownloadUrl(sharedUrl) {
const url = new URL(normalizeDropboxSharedUrl(sharedUrl));
url.searchParams.set('raw', '1');
return url.toString();
}
function setCors(req, res) {
applyCors(req, res, 'GET, OPTIONS');
}
async function getAccessToken() {
const { appKey, appSecret, refreshToken } = getDropboxConfig();
if (!appKey || !appSecret || !refreshToken) {
throw new Error('Dropbox auth env vars are not configured.');
}
const response = await fetch('https://api.dropboxapi.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: DROPBOX_REFRESH_TOKEN,
client_id: DROPBOX_APP_KEY,
client_secret: DROPBOX_APP_SECRET,
refresh_token: refreshToken,
client_id: appKey,
client_secret: appSecret,
})
});
const data = await response.json();
@@ -21,47 +52,81 @@ async function getAccessToken() {
}
export default async function handler(req, res) {
let accessToken;
try {
accessToken = await getAccessToken();
} catch (err) {
console.error('Token refresh error:', err);
return res.status(500).json({ error: err.message });
setCors(req, res);
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: 'Forbidden' });
}
if (req.method === 'GET' && req.query.info === '1') {
try {
const fileInfo = await fetch('https://api.dropboxapi.com/2/files/get_metadata', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
});
const data = await fileInfo.json();
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
return res.json({ rev: data.rev, size: data.size, server_modified: data.server_modified });
} catch (err) {
return res.status(500).json({ error: err.message });
}
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
return res.json({ rev: 'new-url-v1', size: 0, server_modified: new Date().toISOString() });
}
const { sharedUrl } = getDropboxConfig();
const sharingUrlForApi = normalizeDropboxSharedUrl(sharedUrl);
const downloadUrl = buildDropboxDownloadUrl(sharedUrl);
// Try authenticated Dropbox API first — bypasses CDN cache so we always get the latest version.
// Falls back to the shared URL if credentials are not configured.
let upstream = null;
let usedAuth = false;
try {
const upstream = await fetch('https://content.dropboxapi.com/2/files/download', {
const accessToken = await getAccessToken();
const apiRes = await fetch('https://content.dropboxapi.com/2/sharing/get_shared_link_file', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Dropbox-API-Arg': JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
}
'Dropbox-API-Arg': JSON.stringify({ url: sharingUrlForApi }),
'Content-Type': 'text/plain; charset=utf-8',
},
});
if (apiRes.ok) {
upstream = apiRes;
usedAuth = true;
console.log('Dropbox: downloaded via authenticated API (no CDN cache)');
} else {
const errText = await apiRes.text();
console.warn('Dropbox API download failed, falling back to sharing link:', apiRes.status, errText.substring(0, 200));
}
} catch (authErr) {
console.warn('Dropbox auth unavailable, falling back to sharing link:', authErr?.message || String(authErr));
}
try {
if (!upstream) {
upstream = await fetch(downloadUrl, {
method: 'GET',
headers: {
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
});
}
const contentType = upstream.headers.get('content-type');
if (!usedAuth && contentType && contentType.includes('text/html')) {
console.error('Dropbox returned HTML instead of file');
return res.status(500).send('Error: El enlace de Dropbox ha devuelto una página HTML en lugar del archivo. Es probable que el enlace haya caducado o necesite ser renovado.');
}
if (!upstream.ok) {
const errText = await upstream.text();
console.error('Dropbox API error:', upstream.status, errText);
return res.status(upstream.status).send('Dropbox error: ' + errText);
console.error('Dropbox URL error:', upstream.status, errText);
return res.status(upstream.status).send(
'Dropbox error: ' +
errText +
'\n\nSet DROPBOX_SHARED_URL in Vercel if the sharing link changed.'
);
}
const buffer = await upstream.arrayBuffer();
@@ -70,6 +135,6 @@ export default async function handler(req, res) {
res.send(Buffer.from(buffer));
} catch (err) {
console.error('Dropbox proxy error:', err);
res.status(500).send('Proxy error: ' + err.message);
res.status(500).send('Proxy error: ' + (err?.message || String(err)));
}
}
+63 -15
View File
@@ -1,50 +1,98 @@
import { applyCors, isAllowedOrigin } from './_cors.js';
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
function setCors(req, res) {
applyCors(req, res, 'POST, OPTIONS');
}
export default async function handler(req, res) {
setCors(req, res);
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: 'Forbidden' });
}
try {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { rows, fileMeta } = req.body;
const { rows } = req.body;
if (!rows || !Array.isArray(rows)) {
return res.status(400).json({ error: 'Missing rows data' });
}
const articleNoIdx = 0;
// Fetch current product rows so we can preserve internal-only columns
// that do not exist in the live Dropbox workbook.
const { data: existingProducts } = await supabase
.from('products')
.select('product_id, data, status');
const existingProductMap = new Map((existingProducts || []).map(p => [p.product_id, p]));
const productsToUpsert = [];
for (const row of rows) {
const productId = String(row[articleNoIdx]);
if (productId && productId.trim() !== '') {
if (!productId || productId.trim() === '') continue;
const existing = existingProductMap.get(productId);
const dbRowData = existing?.data;
if (dbRowData && Array.isArray(dbRowData)) {
const mergedData = [...row];
while (mergedData.length < dbRowData.length) {
mergedData.push(null);
}
for (let i = 100; i < dbRowData.length; i++) {
const internalValue = dbRowData[i];
if (internalValue !== undefined && internalValue !== null && internalValue !== '') {
mergedData[i] = internalValue;
}
}
productsToUpsert.push({
product_id: productId,
data: mergedData,
status: existing?.status || 'excel',
updated_at: new Date().toISOString()
});
} else {
// Normal excel sync
productsToUpsert.push({
product_id: productId,
data: row,
status: 'synced',
status: 'excel',
updated_at: new Date().toISOString()
});
}
}
console.log('Upserting', productsToUpsert.length, 'products to Supabase...');
console.log('Upserting/updating', productsToUpsert.length, 'products in Supabase...');
const { error } = await supabase
.from('products')
.upsert(productsToUpsert, {
onConflict: 'product_id',
ignoreDuplicates: true
});
// Chunk upsert updates to avoid payload sizes or API limits
const CHUNK_SIZE = 100;
for (let i = 0; i < productsToUpsert.length; i += CHUNK_SIZE) {
const chunk = productsToUpsert.slice(i, i + CHUNK_SIZE);
const { error } = await supabase
.from('products')
.upsert(chunk, { onConflict: 'product_id' });
if (error) {
console.error('Supabase upsert error:', error);
return res.status(500).json({ error: error.message, detail: 'Failed to upsert products' });
if (error) {
console.error('Supabase upsert error in chunk:', error);
return res.status(500).json({ error: error.message, detail: 'Failed to upsert products chunk' });
}
}
return res.json({
+76
View File
@@ -0,0 +1,76 @@
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
export default async function handler(req, res) {
try {
console.log('Starting migration v3...');
// 1. Fetch products
const { data: products, error: pErr } = await supabase
.from('products')
.select('product_id, data')
.in('status', ['edited', 'synced', 'pending']);
if (pErr) return res.status(500).json({ error: 'Fetch products failed', details: pErr });
let pCount = 0;
if (products && products.length > 0) {
for (const p of products) {
const oldData = p.data;
if (!oldData || oldData.length < 13) continue;
const valAt12 = oldData[12];
const isOld = typeof valAt12 === 'number' || (typeof valAt12 === 'string' && /^[0-9.]+$/.test(valAt12));
if (isOld && oldData.length < 110) {
const newData = [...oldData];
newData.splice(12, 0, '');
const { error: updateErr } = await supabase.from('products').update({ data: newData }).eq('product_id', p.product_id);
if (!updateErr) pCount++;
}
}
}
// 2. History
const { data: history, error: hErr } = await supabase.from('products_history').select('*');
if (hErr) return res.status(500).json({ error: 'Fetch history failed', details: hErr });
let hCount = 0;
if (history && history.length > 0) {
for (const h of history) {
let changed = false;
let nOld = h.old_data;
let nNew = h.new_data;
if (nOld && nOld.length >= 13 && (typeof nOld[12] === 'number' || (typeof nOld[12] === 'string' && /^[0-9.]+$/.test(nOld[12])))) {
nOld = [...nOld];
nOld.splice(12, 0, '');
changed = true;
}
if (nNew && nNew.length >= 13 && (typeof nNew[12] === 'number' || (typeof nNew[12] === 'string' && /^[0-9.]+$/.test(nNew[12])))) {
nNew = [...nNew];
nNew.splice(12, 0, '');
changed = true;
}
if (changed) {
const { error: histErr } = await supabase.from('products_history').update({ old_data: nOld, new_data: nNew }).eq('id', h.id);
if (!histErr) hCount++;
}
}
}
return res.json({
success: true,
productsMigrated: pCount,
historyMigrated: hCount,
message: 'Migration v3 finished.'
});
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
+290
View File
@@ -0,0 +1,290 @@
import { applyCors, isAllowedOrigin } from './_cors.js';
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.SUPABASE_SERVICE_KEY;
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const MASTER_USERS = new Set([
'christian.vidal@craze-group.com',
'jingying.shi@craze-group.com',
]);
function getValidatedFromMetadata(user) {
return user?.app_metadata?.validated === true || user?.user_metadata?.validated === true;
}
async function fetchAdminUser(userId) {
const res = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${userId}`, {
headers: {
'apikey': SUPABASE_SERVICE_KEY,
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
}
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Failed to fetch auth user: ${errText}`);
}
const payload = await res.json();
return payload?.user || payload;
}
async function updateAuthValidationMetadata(userId, validated) {
const currentUser = await fetchAdminUser(userId);
const res = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${userId}`, {
method: 'PUT',
headers: {
'apikey': SUPABASE_SERVICE_KEY,
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
app_metadata: {
...(currentUser?.app_metadata || {}),
validated,
},
user_metadata: currentUser?.user_metadata || {},
})
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Failed to update auth metadata: ${errText}`);
}
}
async function fetchApprovalsMap() {
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?select=*`, {
headers: {
'apikey': SUPABASE_SERVICE_KEY,
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
}
});
if (!approvalsRes.ok) {
const errText = await approvalsRes.text();
console.error('Failed to fetch approvals:', errText);
return {
approvalMap: null,
warning: 'Validation table unavailable; using Auth metadata fallback.'
};
}
const approvals = await approvalsRes.json();
return {
approvalMap: new Map(approvals.map(a => [a.id, a.validated])),
warning: null,
};
}
function setCors(req, res) {
applyCors(req, res, 'POST, OPTIONS');
}
export default async function handler(req, res) {
setCors(req, res);
if (req.method === 'OPTIONS') {
return res.status(204).end();
}
const origin = req.headers.origin;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: 'Forbidden' });
}
try {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
// 1. Authenticate caller
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized: Missing token' });
}
const token = authHeader.split(' ')[1];
const userRes = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
headers: {
'apikey': SUPABASE_ANON_KEY,
'Authorization': `Bearer ${token}`
}
});
if (!userRes.ok) {
return res.status(401).json({ error: 'Unauthorized: Invalid token' });
}
const user = await userRes.json();
const callerEmail = user.email;
const callerId = user.id;
if (!callerEmail) {
return res.status(401).json({ error: 'Unauthorized: Invalid user payload' });
}
const isMaster = MASTER_USERS.has(callerEmail.toLowerCase());
const { action } = req.body;
if (!action) {
return res.status(400).json({ error: 'Missing action' });
}
// --- Action: Check Validation Status ---
if (action === 'check-status') {
if (isMaster) {
return res.json({ validated: true });
}
const approvalsRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${callerId}&select=validated`, {
headers: {
'apikey': SUPABASE_SERVICE_KEY,
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
}
});
if (!approvalsRes.ok) {
const errText = await approvalsRes.text();
console.error('Failed to query user approvals:', errText);
return res.json({ validated: getValidatedFromMetadata(user) });
}
const approvals = await approvalsRes.json();
const isApproved = approvals.length > 0
? approvals[0].validated === true
: getValidatedFromMetadata(user);
return res.json({ validated: isApproved });
}
// --- Admin-only Actions ---
if (!isMaster) {
return res.status(403).json({ error: 'Forbidden: Admin access required' });
}
if (action === 'list') {
// Fetch all users from GoTrue Admin API
const usersRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users`, {
headers: {
'apikey': SUPABASE_SERVICE_KEY,
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
}
});
if (!usersRes.ok) {
const errText = await usersRes.text();
console.error('Failed to fetch auth users:', errText);
return res.status(500).json({ error: 'Failed to fetch users from authentication' });
}
const usersData = await usersRes.json();
const authUsers = usersData.users || [];
const { approvalMap, warning } = await fetchApprovalsMap();
const mergedUsers = authUsers.map(u => {
const email = u.email;
const id = u.id;
const createdAt = u.created_at;
let status = 'Pending';
if (MASTER_USERS.has(email?.toLowerCase())) {
status = 'Master';
} else if (approvalMap?.has(id)) {
status = approvalMap.get(id) ? 'Validated' : 'Pending';
} else if (getValidatedFromMetadata(u)) {
status = 'Validated';
}
return { id, email, created_at: createdAt, status };
});
return res.json({ users: mergedUsers, warning });
}
if (action === 'validate') {
const { targetUserId, email, validated } = req.body;
if (!targetUserId || !email) {
return res.status(400).json({ error: 'Missing targetUserId or email' });
}
let tableWarning = null;
try {
const upsertRes = await fetch(`${SUPABASE_URL}/rest/v1/user_approvals`, {
method: 'POST',
headers: {
'apikey': SUPABASE_SERVICE_KEY,
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`,
'Content-Type': 'application/json',
'Prefer': 'resolution=merge-duplicates,return=representation'
},
body: JSON.stringify({
id: targetUserId,
email,
validated,
created_at: new Date().toISOString()
})
});
if (!upsertRes.ok) {
const errText = await upsertRes.text();
console.error('Failed to upsert approval:', errText);
tableWarning = 'Validation table unavailable; Auth metadata was updated instead.';
}
} catch (err) {
console.error('Approval table update threw:', err);
tableWarning = 'Validation table unavailable; Auth metadata was updated instead.';
}
await updateAuthValidationMetadata(targetUserId, validated);
return res.json({ success: true, warning: tableWarning });
}
if (action === 'delete') {
const { targetUserId } = req.body;
if (!targetUserId) {
return res.status(400).json({ error: 'Missing targetUserId' });
}
// Prevent master user self-deletion via API
const targetUserRes = await fetchAdminUser(targetUserId).catch(() => null);
if (targetUserRes && MASTER_USERS.has(targetUserRes.email?.toLowerCase())) {
return res.status(400).json({ error: 'Cannot delete a master user account' });
}
// 1. Delete user from auth
const deleteAuthRes = await fetch(`${SUPABASE_URL}/auth/v1/admin/users/${targetUserId}`, {
method: 'DELETE',
headers: {
'apikey': SUPABASE_SERVICE_KEY,
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
}
});
if (!deleteAuthRes.ok) {
const errText = await deleteAuthRes.text();
console.error('Failed to delete auth user:', errText);
return res.status(500).json({ error: 'Failed to delete user from authentication' });
}
// 2. Delete user approval record from public.user_approvals if exists
await fetch(`${SUPABASE_URL}/rest/v1/user_approvals?id=eq.${targetUserId}`, {
method: 'DELETE',
headers: {
'apikey': SUPABASE_SERVICE_KEY,
'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`
}
});
return res.json({ success: true });
}
return res.status(400).json({ error: 'Invalid action' });
} catch (err) {
console.error('Error in users-admin function:', err);
return res.status(500).json({ error: err.message || 'Internal server error' });
}
}
+190
View File
@@ -0,0 +1,190 @@
import * as XLSX from 'xlsx';
export function getBcConfig(env = process.env) {
const legacyWriteMethod = String(env.BC_WRITE_METHOD || 'PATCH').toUpperCase();
const legacyWriteUrlTemplate = env.BC_WRITE_URL_TEMPLATE || `{{itemsUrl}}('{{itemNo}}')`;
const legacyWriteBodyTemplate = env.BC_WRITE_BODY_TEMPLATE || JSON.stringify({ cpnpNo: '{{cpnpNo}}' });
return {
tenantId: env.BC_TENANT_ID || 'fab724f7-6b6d-4e3b-86e3-8c1e05e36b2a',
clientId: env.BC_CLIENT_ID || '6f832138-cb48-43e7-8601-efca120b45dc',
clientSecret: env.BC_CLIENT_SECRET,
companyId: env.BC_COMPANY_ID || '2acec35c-7d06-ed11-82f8-0022485ceea3',
writeMethod: legacyWriteMethod,
writeUrlTemplate: legacyWriteUrlTemplate,
writeBodyTemplate: legacyWriteBodyTemplate,
itemsWriteMethod: String(env.BC_ITEMS_WRITE_METHOD || legacyWriteMethod).toUpperCase(),
itemsWriteUrlTemplate: env.BC_ITEMS_WRITE_URL_TEMPLATE || legacyWriteUrlTemplate,
itemsWriteBodyTemplate: env.BC_ITEMS_WRITE_BODY_TEMPLATE || null,
itemUnitsCode: env.BC_UOM_CODE || 'OUTER',
itemUnitsWriteMethod: env.BC_UOM_WRITE_METHOD ? String(env.BC_UOM_WRITE_METHOD).toUpperCase() : 'PATCH',
itemUnitsWriteUrlTemplate: env.BC_UOM_WRITE_URL_TEMPLATE || `{{itemUnitsOfMeasureUrl}}(itemNo='{{itemNo}}',code='{{itemUnitsCode}}')`,
itemUnitsWriteBodyTemplate: env.BC_UOM_WRITE_BODY_TEMPLATE || null,
};
}
export function getTokenUrl(config) {
return `https://login.microsoftonline.com/${config.tenantId}/oauth2/v2.0/token`;
}
export function getItemsUrl(config) {
return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/items`;
}
export function getItemUnitsOfMeasureUrl(config) {
return `https://api.businesscentral.dynamics.com/v2.0/${config.tenantId}/production/api/craze/integrations/v1.0/companies(${config.companyId})/itemUnitsOfMeasure2`;
}
let tokenCache = { token: null, expiresAt: 0 };
export async function getBCToken(config) {
const now = Date.now();
if (tokenCache.token && now < tokenCache.expiresAt) {
return tokenCache.token;
}
if (!config.clientSecret) {
throw new Error('BC_CLIENT_SECRET env var not set');
}
const body = new URLSearchParams({
grant_type: 'client_credentials',
client_id: config.clientId,
client_secret: config.clientSecret,
scope: 'https://api.businesscentral.dynamics.com/.default',
});
const res = await fetch(getTokenUrl(config), {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
});
const data = await res.json();
if (!data.access_token) {
throw new Error('BC token error: ' + JSON.stringify(data));
}
tokenCache = { token: data.access_token, expiresAt: now + 55 * 60 * 1000 };
return data.access_token;
}
export async function fetchAllItems(config, token) {
const items = [];
let url = `${getItemsUrl(config)}?$top=1000`;
while (url) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`BC GET items failed (${res.status}): ${txt}`);
}
const json = await res.json();
if (Array.isArray(json.value)) {
items.push(...json.value);
}
url = json['@odata.nextLink'] || null;
}
return items;
}
export async function findItem(config, token, articleNo) {
const filter = encodeURIComponent(`no eq '${articleNo}'`);
const url = `${getItemsUrl(config)}?$filter=${filter}&$select=systemId,no,cpnpNo`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`BC GET items failed (${res.status}): ${txt}`);
}
const json = await res.json();
const items = json.value || [];
console.log('[bc-proxy] GET items result:', JSON.stringify(items));
if (items.length === 0) throw new Error(`Item not found in BC: ${articleNo}`);
return items[0];
}
export async function patchItemCpnpNo(config, token, item, cpnpNo) {
const etag = item['@odata.etag'] || '*';
const itemsUrl = getItemsUrl(config);
const url = config.writeUrlTemplate
.replaceAll('{{itemsUrl}}', itemsUrl)
.replaceAll('{{tenantId}}', config.tenantId)
.replaceAll('{{companyId}}', config.companyId)
.replaceAll('{{itemNo}}', encodeURIComponent(String(item.no)))
.replaceAll('{{systemId}}', encodeURIComponent(String(item.systemId || '')))
.replaceAll('{{cpnpNo}}', String(cpnpNo));
const bodyText = config.writeBodyTemplate
.replaceAll('{{itemsUrl}}', itemsUrl)
.replaceAll('{{tenantId}}', config.tenantId)
.replaceAll('{{companyId}}', config.companyId)
.replaceAll('{{itemNo}}', String(item.no))
.replaceAll('{{systemId}}', String(item.systemId || ''))
.replaceAll('{{cpnpNo}}', String(cpnpNo));
console.log('[bc-proxy] WRITE url:', url);
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
if ((config.writeMethod || 'PATCH') === 'PATCH') {
headers['If-Match'] = etag;
}
const res = await fetch(url, {
method: config.writeMethod || 'PATCH',
headers,
body: bodyText,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`BC write failed (${res.status}): ${txt}`);
}
return true;
}
export function normalizeValue(value) {
if (value === null || value === undefined) return '';
if (value instanceof Date) return value.toISOString();
if (typeof value === 'object') return JSON.stringify(value);
return value;
}
export function buildWorkbook(items) {
const headers = [];
const seen = new Set();
items.forEach(item => {
Object.keys(item || {}).forEach(key => {
if (key.startsWith('@odata.')) return;
if (seen.has(key)) return;
seen.add(key);
headers.push(key);
});
});
const rows = items.map(item => {
const row = {};
headers.forEach(key => {
row[key] = normalizeValue(item?.[key]);
});
return row;
});
const ws = XLSX.utils.json_to_sheet(rows, { header: headers });
ws['!autofilter'] = {
ref: XLSX.utils.encode_range({
s: { c: 0, r: 0 },
e: { c: Math.max(headers.length - 1, 0), r: Math.max(rows.length, 0) },
}),
};
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Items');
return wb;
}
+805
View File
@@ -0,0 +1,805 @@
import crypto from 'crypto';
import { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl } from './bc-runtime.js';
function findHeaderIndex(headers, patterns) {
const normalized = headers.map(h => String(h || '').toLowerCase());
return normalized.findIndex(header =>
patterns.every(pattern => header.includes(pattern.toLowerCase()))
);
}
function findCategorizationCodeIndex(headers) {
const normalized = headers.map(h => String(h || '').toLowerCase().trim());
const categorizationIdx = normalized.findIndex(header => header.replace(/[\s_-]+/g, '') === 'categorizationcode');
if (categorizationIdx >= 0) return categorizationIdx;
const typeIdx = normalized.findIndex(header => header === 'type');
if (typeIdx >= 0) return typeIdx;
return normalized.findIndex(header => header.includes('product') && header.includes('type'));
}
function formatDateForBc(value) {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number' && value >= 25569 && value <= 60000) {
const excelEpoch = new Date(1899, 11, 30);
const date = new Date(excelEpoch.getTime() + value * 86400000);
return date.toISOString().split('T')[0];
}
const str = String(value).trim();
if (!str) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
const parsed = new Date(str);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString().split('T')[0];
}
return str;
}
function getValue(row, index) {
if (index === null || index < 0) return null;
const value = row[index];
return value === undefined || value === '' ? null : value;
}
function toBcDecimal(value) {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number' && Number.isFinite(value)) return value;
const normalized = String(value).trim().replace(/\s+/g, '').replace(',', '.');
if (!normalized) return null;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function makeFieldPreview(sourceLabel, sourceIndex, targetField, value) {
return { sourceLabel, sourceIndex, targetField, value };
}
export function buildBusinessCentralMappingPreview(headers, row) {
const articleNoIdx = findHeaderIndex(headers, ['article', 'no']);
const articleDetailsEnIdx = findHeaderIndex(headers, ['article', 'details', 'english']);
const articleDetailsDeIdx = findHeaderIndex(headers, ['article', 'details', 'german']);
const launchDateIdx = findHeaderIndex(headers, ['launch']);
const readyToOrderDateIdx = findHeaderIndex(headers, ['ready']);
const moqIdx = findHeaderIndex(headers, ['moq']);
const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']);
const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']);
const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
const categorizationCodeIdx = findCategorizationCodeIndex(headers);
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
const units40HqIdx = (() => {
const hqIdx = findHeaderIndex(headers, ['40', 'hq']);
if (hqIdx >= 0) return hqIdx;
return findHeaderIndex(headers, ['40', 'hc']);
})();
const innerWIdx = findHeaderIndex(headers, ['inner', 'w']);
const innerLIdx = findHeaderIndex(headers, ['inner', 'l']);
const innerHIdx = findHeaderIndex(headers, ['inner', 'h']);
const outerWIdx = findHeaderIndex(headers, ['outer', 'w']);
const outerLIdx = findHeaderIndex(headers, ['outer', 'l']);
const outerHIdx = findHeaderIndex(headers, ['outer', 'h']);
const articleNo = String(getValue(row, articleNoIdx) ?? '');
const categorizationCode = getValue(row, categorizationCodeIdx);
const hasCategorizationCode = String(categorizationCode ?? '').trim() !== '';
const itemsPayload = {
no: getValue(row, articleNoIdx),
articleDetailsEnglish: getValue(row, articleDetailsEnIdx),
articleDetailsGerman: getValue(row, articleDetailsDeIdx),
launchDate: formatDateForBc(getValue(row, launchDateIdx)),
readyToOrderDate: formatDateForBc(getValue(row, readyToOrderDateIdx)),
minimumOrderQuantity: toBcDecimal(getValue(row, moqIdx)),
shortDescriptionInGerman: getValue(row, shortDeIdx),
shortDescriptionInEnglish: getValue(row, shortEnIdx),
cpnpNo: getValue(row, cpnpIdx),
};
if (hasCategorizationCode) {
itemsPayload.categorizationCode = String(categorizationCode).trim();
}
const itemUnitsOfMeasurePayload = {
itemNo: getValue(row, articleNoIdx),
code: 'OUTER',
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, unitsOuterIdx)),
width: toBcDecimal(getValue(row, outerWIdx)),
length: toBcDecimal(getValue(row, outerLIdx)),
height: toBcDecimal(getValue(row, outerHIdx)),
};
const itemUnits40HCPayload = {
itemNo: getValue(row, articleNoIdx),
code: '40HC',
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
};
return {
articleNo,
itemsPayload,
itemUnitsOfMeasurePayload,
itemUnits40HCPayload,
itemsFields: [
makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no),
makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish),
makeFieldPreview('Article Details - German', articleDetailsDeIdx, 'articleDetailsGerman', itemsPayload.articleDetailsGerman),
makeFieldPreview('Launch Date', launchDateIdx, 'launchDate', itemsPayload.launchDate),
makeFieldPreview('Ready to Order Date', readyToOrderDateIdx, 'readyToOrderDate', itemsPayload.readyToOrderDate),
makeFieldPreview('MOQ', moqIdx, 'minimumOrderQuantity', itemsPayload.minimumOrderQuantity),
makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman),
makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish),
makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo),
...(hasCategorizationCode
? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)]
: []),
],
itemUnitsFields: [
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo),
makeFieldPreview('Units Outer', unitsOuterIdx, 'qtyPerUnitOfMeasure', itemUnitsOfMeasurePayload.qtyPerUnitOfMeasure),
makeFieldPreview('Outer W (cm)', outerWIdx, 'width', itemUnitsOfMeasurePayload.width),
makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length),
makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height),
],
itemUnits40HCFields: [
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnits40HCPayload.itemNo),
makeFieldPreview('Units 40FT HQ', units40HqIdx, 'qtyPerUnitOfMeasure', itemUnits40HCPayload.qtyPerUnitOfMeasure),
],
};
}
function normalizeForComparison(field, value) {
const strField = String(field || '').toLowerCase();
if (DECIMAL_FIELDS.has(field) && (value === null || value === undefined || value === '')) {
return '0';
}
if (strField.includes('date')) {
if (value === null || value === undefined || value === '') {
return '0001-01-01';
}
const normalizedDate = formatDateForBc(value);
return normalizedDate === '0001-01-01' ? '0001-01-01' : normalizedDate;
}
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'string') {
return value.replace(/\r\n/g, '\n').trimEnd();
}
if (typeof value === 'number' && Number.isFinite(value)) {
return String(value);
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
return String(value).trim();
}
function buildFieldChanges(fields, currentRecord, desiredPayload) {
return fields
.filter(field => field.targetField !== 'itemNo' && field.targetField !== 'no')
.map(field => {
const before = currentRecord ? currentRecord[field.targetField] : undefined;
const after = desiredPayload[field.targetField];
const normalizedBefore = normalizeForComparison(field.targetField, before);
const normalizedAfter = normalizeForComparison(field.targetField, after);
return {
sourceLabel: field.sourceLabel,
sourceIndex: field.sourceIndex,
targetField: field.targetField,
before,
after,
changed: normalizedBefore !== normalizedAfter,
};
});
}
function describeUnappliedChanges(sectionLabel, changes) {
return changes
.filter(change => change.changed)
.map(change => `${change.sourceLabel} (${change.targetField}): expected ${JSON.stringify(change.after)} but BC has ${JSON.stringify(change.before)}`)
.join('; ');
}
function buildChangedPayload(changes) {
const payload = {};
changes
.filter(change => change.changed)
.forEach(change => {
const field = change.targetField;
const value = change.after;
if (DECIMAL_FIELDS.has(field)) {
payload[field] = toBcDecimal(value);
return;
}
if (EMPTY_STRING_FIELDS.has(field)) {
payload[field] = value === null || value === undefined ? '' : String(value);
return;
}
if (String(field || '').toLowerCase().includes('date')) {
payload[field] = formatDateForBc(value);
return;
}
payload[field] = value;
});
return payload;
}
function buildPreviewHash(payload) {
return crypto.createHash('sha256').update(JSON.stringify(payload)).digest('hex');
}
function getItemsSelect() {
return [
'systemId',
'no',
'articleDetailsEnglish',
'articleDetailsGerman',
'launchDate',
'readyToOrderDate',
'minimumOrderQuantity',
'shortDescriptionInGerman',
'shortDescriptionInEnglish',
'cpnpNo',
'categorizationCode',
].join(',');
}
function getItemUnitsSelect() {
return [
'itemNo',
'code',
'qtyPerUnitOfMeasure',
'qtyRoundingPrecision',
'length',
'width',
'height',
'cubage',
'weight',
'layerPerPalet2CRZ',
'outerPerLayer2CRZ',
'barCodeCRZ',
'layerPerPaletCRZ',
'outerPerLayerCRZ',
'netWeightCRZ',
'innerTypeBCT',
].join(',');
}
async function fetchJsonOrThrow(url, token, label) {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`${label} failed (${res.status}): ${txt}`);
}
return {
json: await res.json(),
etag: res.headers.get('etag') || null,
};
}
export async function fetchBusinessCentralSnapshot(config, token, articleNo) {
const itemsFilter = encodeURIComponent(`no eq '${articleNo}'`);
const itemsUrl = `${getItemsUrl(config)}?$filter=${itemsFilter}&$select=${getItemsSelect()}`;
const { json: itemsJson, etag: itemsEtag } = await fetchJsonOrThrow(itemsUrl, token, 'BC GET items');
const item = Array.isArray(itemsJson.value) && itemsJson.value.length > 0 ? itemsJson.value[0] : null;
const outerCode = config.itemUnitsCode || 'OUTER';
const uomUrl = getItemUnitsOfMeasureUrl(config);
const outerFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '${outerCode}'`);
const outerUrl = `${uomUrl}?$filter=${outerFilter}&$select=${getItemUnitsSelect()}`;
const { json: outerJson, etag: outerEtag } = await fetchJsonOrThrow(outerUrl, token, 'BC GET itemUnitsOfMeasure OUTER');
const itemUnitsOfMeasure = Array.isArray(outerJson.value) && outerJson.value.length > 0 ? outerJson.value[0] : null;
const hcFilter = encodeURIComponent(`itemNo eq '${articleNo}' and code eq '40HC'`);
const hcUrl = `${uomUrl}?$filter=${hcFilter}&$select=${getItemUnitsSelect()}`;
const { json: hcJson, etag: hcEtag } = await fetchJsonOrThrow(hcUrl, token, 'BC GET itemUnitsOfMeasure 40HC');
const itemUnits40HC = Array.isArray(hcJson.value) && hcJson.value.length > 0 ? hcJson.value[0] : null;
return {
item,
itemUnitsOfMeasure,
itemUnits40HC,
itemEtag: item?.['@odata.etag'] || itemsEtag || '*',
itemUnitsEtag: itemUnitsOfMeasure?.['@odata.etag'] || outerEtag || '*',
itemUnits40HCEtag: itemUnits40HC?.['@odata.etag'] || hcEtag || '*',
};
}
function makePreviewSection({
type,
desiredPayload,
currentRecord,
fieldPreviews,
writeMethod,
writeUrlTemplate,
writeBodyTemplate,
supported = true,
supportReason = null,
}) {
const changes = buildFieldChanges(fieldPreviews, currentRecord, desiredPayload);
return {
type,
desired: desiredPayload,
current: currentRecord || null,
changes,
changedFields: changes.filter(change => change.changed).map(change => change.targetField),
writeConfigured: Boolean(writeUrlTemplate),
writeMethod: writeMethod || null,
writeUrlTemplate: writeUrlTemplate || null,
writeBodyTemplate: writeBodyTemplate || null,
canApply: Boolean(writeUrlTemplate) && supported,
supported,
supportReason,
};
}
export function buildBusinessCentralSyncPreview(config, mapping, snapshot) {
const itemsSection = makePreviewSection({
type: 'items',
desiredPayload: mapping.itemsPayload,
currentRecord: snapshot.item,
fieldPreviews: mapping.itemsFields,
writeMethod: config.itemsWriteMethod || config.writeMethod,
writeUrlTemplate: config.itemsWriteUrlTemplate || config.writeUrlTemplate,
writeBodyTemplate: config.itemsWriteBodyTemplate || null,
});
const itemUnitsSection = makePreviewSection({
type: 'itemUnitsOfMeasure',
desiredPayload: mapping.itemUnitsOfMeasurePayload,
currentRecord: snapshot.itemUnitsOfMeasure,
fieldPreviews: mapping.itemUnitsFields,
writeMethod: config.itemUnitsWriteMethod || null,
writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null,
writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null,
});
const itemUnits40HCSection = makePreviewSection({
type: 'itemUnits40HC',
desiredPayload: mapping.itemUnits40HCPayload,
currentRecord: snapshot.itemUnits40HC,
fieldPreviews: mapping.itemUnits40HCFields,
writeMethod: config.itemUnits40HCWriteMethod || config.itemUnitsWriteMethod || null,
writeUrlTemplate: config.itemUnits40HCWriteUrlTemplate || config.itemUnitsWriteUrlTemplate || null,
writeBodyTemplate: config.itemUnits40HCWriteBodyTemplate || config.itemUnitsWriteBodyTemplate || null,
});
const previewPayload = {
articleNo: mapping.articleNo,
items: itemsSection,
itemUnitsOfMeasure: itemUnitsSection,
itemUnits40HC: itemUnits40HCSection,
};
return {
...previewPayload,
hasChanges:
itemsSection.changes.some(change => change.changed) ||
itemUnitsSection.changes.some(change => change.changed) ||
itemUnits40HCSection.changes.some(change => change.changed),
previewToken: buildPreviewHash({
articleNo: mapping.articleNo,
items: itemsSection.changes,
itemUnitsOfMeasure: itemUnitsSection.changes,
itemUnits40HC: itemUnits40HCSection.changes,
}),
};
}
export async function previewBusinessCentralSync(config, token, headers, row) {
const mapping = buildBusinessCentralMappingPreview(headers, row);
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
return buildBusinessCentralSyncPreview(config, mapping, snapshot);
}
async function renderAndPatchRecord({ token, url, method, body, etag }) {
const headers = {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
};
if (String(method || 'PATCH').toUpperCase() === 'PATCH') {
headers['If-Match'] = etag || '*';
}
const res = await fetch(url, {
method: method || 'PATCH',
headers,
body,
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`BC write failed (${res.status}): ${txt}`);
}
}
function renderTemplate(template, context) {
return String(template).replace(/\{\{\s*([^}]+?)\s*\}\}/g, (_match, key) => {
const value = context[key];
if (value === null || value === undefined) return '';
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
});
}
const DECIMAL_FIELDS = new Set([
'minimumOrderQuantity',
'qtyPerUnitOfMeasure',
'length',
'width',
'height',
'cubage',
'weight',
'layerPerPalet2CRZ',
'outerPerLayer2CRZ',
'netWeightCRZ',
]);
const EMPTY_STRING_FIELDS = new Set([
'articleDetailsEnglish',
'articleDetailsGerman',
'shortDescriptionInGerman',
'shortDescriptionInEnglish',
'cpnpNo',
]);
function normalizeDecimalPayload(value) {
if (Array.isArray(value)) {
return value.map(normalizeDecimalPayload);
}
if (!value || typeof value !== 'object') {
return value;
}
const next = {};
for (const [key, nested] of Object.entries(value)) {
if (EMPTY_STRING_FIELDS.has(key) && (nested === null || nested === undefined)) {
next[key] = '';
continue;
}
if (DECIMAL_FIELDS.has(key)) {
next[key] = toBcDecimal(nested);
continue;
}
next[key] = normalizeDecimalPayload(nested);
}
return next;
}
function renderJsonBody(template, context, fallbackPayload) {
const rendered = template ? renderTemplate(template, context) : JSON.stringify(fallbackPayload);
if (typeof rendered !== 'string') {
return JSON.stringify(normalizeDecimalPayload(rendered));
}
try {
const parsed = JSON.parse(rendered);
return JSON.stringify(normalizeDecimalPayload(parsed));
} catch {
return rendered;
}
}
async function applyItemsSection(config, token, snapshot, preview, context) {
const changedFields = preview.items.changes.filter(change => change.changed);
if (changedFields.length === 0) {
return { applied: false, reason: 'No item changes' };
}
if (!config.itemsWriteUrlTemplate) {
return { applied: false, reason: 'Items write template not configured' };
}
const url = renderTemplate(config.itemsWriteUrlTemplate, context);
const payload = renderJsonBody(preview.items.writeBodyTemplate, context, buildChangedPayload(preview.items.changes));
await renderAndPatchRecord({
token,
url,
method: preview.items.writeMethod || 'PATCH',
body: payload,
etag: snapshot.itemEtag || snapshot.item?.['@odata.etag'] || '*',
});
return { applied: true, url };
}
async function applyItemUnitsSection(config, token, snapshot, preview, context) {
const changedFields = preview.itemUnitsOfMeasure.changes.filter(change => change.changed);
if (changedFields.length === 0) {
return { applied: false, reason: 'No itemUnitsOfMeasure changes' };
}
if (!preview.itemUnitsOfMeasure.supported) {
return { applied: false, reason: preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only' };
}
if (!config.itemUnitsWriteUrlTemplate) {
return { applied: false, reason: 'itemUnitsOfMeasure write template not configured' };
}
const url = renderTemplate(config.itemUnitsWriteUrlTemplate, context);
const payload = renderJsonBody(preview.itemUnitsOfMeasure.writeBodyTemplate, context, buildChangedPayload(preview.itemUnitsOfMeasure.changes));
await renderAndPatchRecord({
token,
url,
method: preview.itemUnitsOfMeasure.writeMethod || 'PATCH',
body: payload,
etag: snapshot.itemUnitsEtag || snapshot.itemUnitsOfMeasure?.['@odata.etag'] || '*',
});
return { applied: true, url };
}
async function applyItemUnits40HCSection(config, token, snapshot, preview, context) {
const changedFields = preview.itemUnits40HC.changes.filter(change => change.changed);
if (changedFields.length === 0) {
return { applied: false, reason: 'No itemUnits40HC changes' };
}
if (!config.itemUnits40HCWriteUrlTemplate && !config.itemUnitsWriteUrlTemplate) {
return { applied: false, reason: 'itemUnits40HC write template not configured' };
}
const urlTemplate = config.itemUnits40HCWriteUrlTemplate || config.itemUnitsWriteUrlTemplate;
const writeMethod = preview.itemUnits40HC.writeMethod || 'PATCH';
const hcContext = {
...context,
itemUnitsCode: '40HC',
itemUnits40HCCode: '40HC',
itemUnitsPayload: preview.itemUnits40HC.desired,
};
const url = renderTemplate(urlTemplate, hcContext);
const payload = renderJsonBody(preview.itemUnits40HC.writeBodyTemplate, hcContext, buildChangedPayload(preview.itemUnits40HC.changes));
await renderAndPatchRecord({
token,
url,
method: writeMethod,
body: payload,
etag: snapshot.itemUnits40HCEtag || snapshot.itemUnits40HC?.['@odata.etag'] || '*',
});
return { applied: true, url };
}
export async function applyBusinessCentralSync(config, token, headers, row, previewToken) {
const mapping = buildBusinessCentralMappingPreview(headers, row);
const snapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
const preview = buildBusinessCentralSyncPreview(config, mapping, snapshot);
const hasItemUnitsChanges = preview.itemUnitsOfMeasure.changes.some(change => change.changed);
if (hasItemUnitsChanges && !snapshot.itemUnitsOfMeasure) {
const error = new Error(`BC itemUnitsOfMeasure row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`);
error.statusCode = 409;
throw error;
}
const hasItemUnits40HCChanges = preview.itemUnits40HC.changes.some(change => change.changed);
if (hasItemUnits40HCChanges && !snapshot.itemUnits40HC) {
const error = new Error(`BC itemUnits40HC row missing for ${mapping.articleNo}. This BC API cannot update these fields until the row exists or BC exposes an upsert action.`);
error.statusCode = 409;
throw error;
}
if (previewToken && previewToken !== preview.previewToken) {
const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
error.statusCode = 409;
throw error;
}
const context = {
itemsUrl: getItemsUrl(config),
itemUnitsOfMeasureUrl: getItemUnitsOfMeasureUrl(config),
articleNo: mapping.articleNo,
itemNo: mapping.articleNo,
itemUnitsCode: config.itemUnitsCode || 'OUTER',
itemUnits40HCCode: '40HC',
systemId: snapshot.item?.systemId || '',
cpnpNo: mapping.itemsPayload.cpnpNo,
itemsPayload: preview.items.desired,
itemUnitsPayload: preview.itemUnitsOfMeasure.desired,
itemUnits40HCPayload: preview.itemUnits40HC.desired,
...preview.items.desired,
...preview.itemUnitsOfMeasure.desired,
...preview.itemUnits40HC.desired,
};
const results = {
items: await applyItemsSection(config, token, snapshot, preview, context),
itemUnitsOfMeasure: await applyItemUnitsSection(config, token, snapshot, preview, context),
itemUnits40HC: await applyItemUnits40HCSection(config, token, snapshot, preview, context),
};
if (results.items.applied || results.itemUnitsOfMeasure.applied || results.itemUnits40HC.applied) {
const verificationSnapshot = await fetchBusinessCentralSnapshot(config, token, mapping.articleNo);
if (results.items.applied) {
const itemMismatches = preview.items.changes
.filter(change => change.changed)
.map(change => ({
...change,
before: verificationSnapshot.item ? verificationSnapshot.item[change.targetField] : undefined,
}))
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
if (itemMismatches.length > 0) {
const error = new Error(`BC verification failed for items: ${describeUnappliedChanges('items', itemMismatches)}`);
error.statusCode = 409;
throw error;
}
}
if (results.itemUnitsOfMeasure.applied) {
const uomMismatches = preview.itemUnitsOfMeasure.changes
.filter(change => change.changed)
.map(change => ({
...change,
before: verificationSnapshot.itemUnitsOfMeasure ? verificationSnapshot.itemUnitsOfMeasure[change.targetField] : undefined,
}))
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
if (uomMismatches.length > 0) {
const error = new Error(`BC verification failed for itemUnitsOfMeasure: ${describeUnappliedChanges('itemUnitsOfMeasure', uomMismatches)}`);
error.statusCode = 409;
throw error;
}
}
if (results.itemUnits40HC.applied) {
const hcMismatches = preview.itemUnits40HC.changes
.filter(change => change.changed)
.map(change => ({
...change,
before: verificationSnapshot.itemUnits40HC ? verificationSnapshot.itemUnits40HC[change.targetField] : undefined,
}))
.filter(change => normalizeForComparison(change.targetField, change.before) !== normalizeForComparison(change.targetField, change.after));
if (hcMismatches.length > 0) {
const error = new Error(`BC verification failed for itemUnits40HC: ${describeUnappliedChanges('itemUnits40HC', hcMismatches)}`);
error.statusCode = 409;
throw error;
}
}
}
return {
success: true,
articleNo: mapping.articleNo,
previewToken: preview.previewToken,
results,
preview,
warning: (preview.itemUnitsOfMeasure.changes.some(change => change.changed) && !preview.itemUnitsOfMeasure.supported)
? (preview.itemUnitsOfMeasure.supportReason || 'itemUnitsOfMeasure sync is not supported by this BC API yet; preview only')
: (preview.itemUnits40HC.changes.some(change => change.changed) && !preview.itemUnits40HC.supported)
? (preview.itemUnits40HC.supportReason || 'itemUnits40HC sync is not supported by this BC API yet; preview only')
: undefined,
};
}
export async function previewBusinessCentralCpnp(config, token, articleNo, cpnpNo) {
const { json } = await fetchJsonOrThrow(
`${getItemsUrl(config)}?$filter=${encodeURIComponent(`no eq '${articleNo}'`)}&$select=${getItemsSelect()}`,
token,
'BC GET items'
);
const item = Array.isArray(json.value) && json.value.length > 0 ? json.value[0] : null;
if (!item) {
throw new Error(`Item not found in BC: ${articleNo}`);
}
const desired = { cpnpNo: String(cpnpNo) };
const current = { cpnpNo: item.cpnpNo };
const changes = buildFieldChanges(
[{ sourceLabel: 'CPNP', sourceIndex: null, targetField: 'cpnpNo' }],
current,
desired
);
return {
articleNo,
items: {
type: 'items',
desired,
current,
changes,
changedFields: changes.filter(change => change.changed).map(change => change.targetField),
writeConfigured: Boolean(config.writeUrlTemplate),
writeMethod: config.writeMethod || 'PATCH',
writeUrlTemplate: config.writeUrlTemplate || null,
writeBodyTemplate: config.writeBodyTemplate || null,
canApply: Boolean(config.writeUrlTemplate),
},
itemUnitsOfMeasure: {
type: 'itemUnitsOfMeasure',
desired: {},
current: null,
changes: [],
changedFields: [],
writeConfigured: Boolean(config.itemUnitsWriteUrlTemplate),
writeMethod: config.itemUnitsWriteMethod || null,
writeUrlTemplate: config.itemUnitsWriteUrlTemplate || null,
writeBodyTemplate: config.itemUnitsWriteBodyTemplate || null,
canApply: false,
},
hasChanges: changes.some(change => change.changed),
previewToken: buildPreviewHash({
articleNo,
cpnpNo: String(cpnpNo),
current: item.cpnpNo,
}),
};
}
export async function applyBusinessCentralCpnp(config, token, articleNo, cpnpNo, previewToken) {
const preview = await previewBusinessCentralCpnp(config, token, articleNo, cpnpNo);
if (previewToken && previewToken !== preview.previewToken) {
const error = new Error('Preview token mismatch. BC data changed or preview is stale.');
error.statusCode = 409;
throw error;
}
const item = preview.items.current;
const url = renderTemplate(config.writeUrlTemplate || `{{itemsUrl}}('{{itemNo}}')`, {
itemsUrl: getItemsUrl(config),
articleNo,
itemNo: articleNo,
cpnpNo: String(cpnpNo),
systemId: item?.systemId || '',
itemsPayload: { cpnpNo: String(cpnpNo) },
});
const body = config.writeBodyTemplate
? renderTemplate(config.writeBodyTemplate, {
itemsUrl: getItemsUrl(config),
articleNo,
itemNo: articleNo,
cpnpNo: String(cpnpNo),
systemId: item?.systemId || '',
itemsPayload: { cpnpNo: String(cpnpNo) },
})
: JSON.stringify({ cpnpNo: String(cpnpNo) });
await renderAndPatchRecord({
token,
url,
method: config.writeMethod || 'PATCH',
body,
etag: item?.['@odata.etag'] || '*',
});
return {
success: true,
articleNo,
cpnpNo: String(cpnpNo),
previewToken: preview.previewToken,
};
}
export { getBcConfig, getBCToken, getItemsUrl, getItemUnitsOfMeasureUrl };
+3
View File
@@ -0,0 +1,3 @@
Credit balance is too low
SessionEnd hook [node "${CLAUDE_PLUGIN_ROOT}/hooks/session-end-cleanup.mjs"] failed: /bin/sh: node: command not found
BIN
View File
Binary file not shown.
@@ -0,0 +1,171 @@
# Maximize Tab View — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add fullscreen maximize mode to each tab view, hiding Sidebar and TopBar for maximum data visibility.
**Architecture:** Each view component manages its own `isFullscreen` state locally. No global state. Toggle between normal and fullscreen modes with conditional rendering.
**Tech Stack:** React useState, lucide-react icons, Tailwind CSS
---
### Task 1: ProductDescriptions.tsx
**Files:**
- Modify: `src/components/ProductDescriptions.tsx`
- [ ] **Step 1: Add imports and state**
Add `Maximize2` to lucide-react imports.
Add `useState` if not present.
```tsx
const [isFullscreen, setIsFullscreen] = useState(false);
```
- [ ] **Step 2: Add maximize button**
Find the component's header section (where stats/controls are rendered).
Add maximize button with `Maximize2` icon next to existing controls.
```tsx
<button
onClick={() => setIsFullscreen(true)}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-slate-700 rounded transition-colors"
title="Fullscreen"
>
<Maximize2 className="w-5 h-5" />
</button>
```
- [ ] **Step 3: Wrap content in conditional render**
After existing return statement, wrap in:
```tsx
return (
<>
{isFullscreen ? (
<div className="fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto">
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
title="Exit fullscreen"
>
<X className="w-5 h-5" />
</button>
{/* Existing JSX content - remove any overflow constraints */}
<div className="[content goes here]" />
</div>
) : (
<div className="...">
{/* Existing JSX content - keep current overflow settings */}
<div className="[content goes here]" />
</div>
)}
</>
);
```
---
### Task 2: MatrixView.tsx
**Files:**
- Modify: `src/components/MatrixView.tsx`
- [ ] **Step 1: Add imports and state**
Add `Maximize2`, `X` to lucide-react imports.
Add `useState` if not present.
- [ ] **Step 2: Add maximize button**
Add button in the header area (search bar section).
- [ ] **Step 3: Add conditional render**
Same pattern as Task 1.
---
### Task 3: DimensionsView.tsx
**Files:**
- Modify: `src/components/DimensionsView.tsx`
- [ ] **Step 1: Add imports and state**
- [ ] **Step 2: Add maximize button**
- [ ] **Step 3: Add conditional render**
---
### Task 4: PricingView.tsx
**Files:**
- Modify: `src/components/PricingView.tsx`
- [ ] **Step 1: Add imports and state**
- [ ] **Step 2: Add maximize button**
- [ ] **Step 3: Add conditional render**
---
### Task 5: ArticleDetails.tsx
**Files:**
- Modify: `src/components/ArticleDetails.tsx`
- [ ] **Step 1: Add imports and state**
- [ ] **Step 2: Add maximize button**
- [ ] **Step 3: Add conditional render**
---
### Task 6: PendingValidationView.tsx
**Files:**
- Modify: `src/components/PendingValidationView.tsx`
- [ ] **Step 1: Add imports and state**
- [ ] **Step 2: Add maximize button**
- [ ] **Step 3: Add conditional render**
---
### Task 7: MissingDataView.tsx
**Files:**
- Modify: `src/components/MissingDataView.tsx`
- [ ] **Step 1: Add imports and state**
- [ ] **Step 2: Add maximize button**
- [ ] **Step 3: Add conditional render**
---
### Task 8: HistoryView.tsx
**Files:**
- Modify: `src/components/HistoryView.tsx`
- [ ] **Step 1: Add imports and state**
- [ ] **Step 2: Add maximize button**
- [ ] **Step 3: Add conditional render**
---
**Verification:** Run `npm run lint` to ensure no TypeScript errors.
@@ -0,0 +1,112 @@
# Maximize Tab View — Spec
## Concept & Vision
Each tab view (Matrix, Product Descriptions, Dimensions, etc.) can be maximized to fullscreen mode, hiding all navigation chrome (Sidebar, TopBar) to maximize data visibility. A floating close button allows returning to normal view. The effect is immersive and focused — like pressing F11 in a browser.
## Design
### Normal Mode
- Sidebar (240px) visible on left
- TopBar visible on top
- Main content fills remaining space
### Maximized Mode
- Sidebar: `display: none`
- TopBar: `display: none`
- Fullscreen overlay covers entire viewport
- Background: `#041021` (matches main content bg)
- Content area: max-height `100vh`, overflow scroll
- Floating close button: top-right corner, fixed position
### Close Button
- Position: fixed, top-right
- Size: 40x40px
- Background: `rgba(30, 41, 59, 0.9)` with blur
- Border: 1px `slate-600`
- Icon: `X` from lucide-react, white, 20px
- Hover: bg `slate-700`, scale 1.05
- Z-index: 50
- Tooltip: "Exit fullscreen"
## Layout & Structure
### Implementation Pattern
Each view component receives `isFullscreen?: boolean` prop and renders:
```
{maximizeButton && (
<button onClick={toggleFullscreen} className="..." title="Fullscreen">
<Maximize2 />
</button>
)}
{isFullscreen ? (
<div className="fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto">
<button onClick={toggleFullscreen} className="fixed top-4 right-4 ...">
<X />
</button>
{/* Tab content rendered at full height */}
</div>
) : (
<div className="...">
{/* Tab content normal */}
</div>
)}
```
### State Management
- Each tab manages its own `isFullscreen` state locally
- No global state needed — each tab remembers its own fullscreen mode
- State resets to normal when switching tabs (intentional)
## Features & Interactions
### Toggle Button
- Location: Each tab view has a maximize button (icon) in its header area
- Icon: `Maximize2` from lucide-react
- Click: Enter fullscreen mode
- Hover: Slight scale increase, cursor pointer
### Exit Fullscreen
- Click floating X button
- Instantly returns to normal layout
- No animation needed
## Component Inventory
### MaximizeButton (in each tab header)
- States: default, hover
- Color: `slate-400` default, `slate-200` hover
### FullscreenCloseButton (floating)
- States: default, hover, active
- Default: semi-transparent dark bg, subtle border
- Hover: lighter bg, slight scale
- Z-index ensures it's above all content
### FullscreenOverlay
- Fixed positioning
- Matches app background color
- Contains scrollable content
## Technical Approach
- Add `isFullscreen` state to each view component
- Add toggle function
- Conditional rendering based on state
- Icons from `lucide-react`: `Maximize2`, `X`
- All styling via Tailwind classes
- No additional dependencies
## Affected Components
1. `ProductDescriptions.tsx`
2. `MatrixView.tsx`
3. `DimensionsView.tsx`
4. `PricingView.tsx`
5. `ArticleDetails.tsx`
6. `PendingValidationView.tsx`
7. `MissingDataView.tsx`
8. `HistoryView.tsx`
-7
View File
@@ -12,7 +12,6 @@
"@supabase/supabase-js": "^2.103.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"buzz": "^2.0.0",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"dropbox": "^10.34.0",
@@ -1931,12 +1930,6 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/buzz": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buzz/-/buzz-2.0.0.tgz",
"integrity": "sha512-eYeTETPJp7hWUX7j3o8iJNR8VLaaWRuOYYPWTSQqA5pIxZ2g3ZJUdyjfNZnGvr85IDhfr4ONQQflGp8+MC034A==",
"license": "MIT"
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
-1
View File
@@ -15,7 +15,6 @@
"@supabase/supabase-js": "^2.103.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"buzz": "^2.0.0",
"clsx": "^2.1.1",
"dotenv": "^17.2.3",
"dropbox": "^10.34.0",
+18
View File
@@ -0,0 +1,18 @@
import * as XLSX from 'xlsx';
import * as fs from 'fs';
const buf = fs.readFileSync('data (3).xlsx');
const wb = XLSX.read(buf, {type: 'buffer'});
const ws = wb.Sheets[wb.SheetNames[0]];
const data = XLSX.utils.sheet_to_json(ws, {header: 1});
const itemRow = data.find(r => String(r[0]) === '75432');
if (itemRow) {
console.log('Row for 75432:');
console.log(`Index 0 (Article No): ${itemRow[0]}`);
console.log(`Index 31 (Units Inner): ${itemRow[31]}`);
console.log(`Index 32 (Units Outer): ${itemRow[32]}`);
console.log(`Index 33 (Units Pallet): ${itemRow[33]}`);
} else {
console.log('Item 75432 not found');
}
Binary file not shown.
+13
View File
@@ -0,0 +1,13 @@
import * as XLSX from 'xlsx';
import * as fs from 'fs';
const buf = fs.readFileSync('data (3).xlsx');
const wb = XLSX.read(buf, {type: 'buffer'});
const ws = wb.Sheets[wb.SheetNames[0]];
const data = XLSX.utils.sheet_to_json(ws, {header: 1});
const headers = data[0];
console.log('Headers:');
headers.forEach((h, i) => {
console.log(`${i}: ${h}`);
});
+10
View File
@@ -0,0 +1,10 @@
{
"version": 1,
"skills": {
"find-skills": {
"source": "vercel-labs/skills",
"sourceType": "github",
"computedHash": "9e1c8b3103f92fa8092568a44fe64858de7c5c9dc65ce4bea8f168080e889cfd"
}
}
}
+1
View File
@@ -0,0 +1 @@
../.agents/skills/find-skills
+823 -158
View File
File diff suppressed because it is too large Load Diff
+53 -10
View File
@@ -1,18 +1,24 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X } from 'lucide-react';
import React, { useEffect, useState, useMemo } from 'react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { SyncStatusPill } from './SyncStatusPill';
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
interface ArticleDetailsProps {
data: ExcelRow[];
onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
dashboardDrilldown?: DashboardDrilldownRequest | null;
onDashboardDrilldownApplied?: () => void;
}
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
export function ArticleDetails({ data, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ArticleDetailsProps) {
const COLUMNS = useColumns();
const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState('');
@@ -22,6 +28,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
[COLUMNS.ARTICLE_NO]: 100,
[COLUMNS.ARTICLE_NAME]: 200,
@@ -31,6 +38,18 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
[COLUMNS.DETAILS_EN]: 150,
});
useEffect(() => {
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'article_details') return;
const focus = dashboardDrilldown.focus as TabType;
setActiveTab(focus);
setSearch('');
setLineFilter('');
setColumnFilters({});
setPage(1);
onDashboardDrilldownApplied?.();
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied]);
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
const filteredData = useMemo(() => {
@@ -159,9 +178,19 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
];
return (
<div className="flex flex-col h-full">
<div className="flex flex-wrap gap-2 mb-6">
{tabs.map(tab => (
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex flex-col h-full"}>
{isFullscreen && (
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
title="Exit fullscreen"
>
<X className="w-5 h-5" />
</button>
)}
<div className="flex items-center justify-between mb-6">
<div className="flex flex-wrap gap-2">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => { setActiveTab(tab.id); setPage(1); }}
@@ -175,6 +204,14 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
{tab.label}
</button>
))}
</div>
<button
onClick={() => setIsFullscreen(true)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
title="Fullscreen"
>
<Maximize2 className="w-5 h-5" />
</button>
</div>
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
@@ -244,6 +281,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
)}
</span>
<button
id={`details-filter-trigger-${col}`}
onClick={(e) => {
e.stopPropagation();
setOpenFilterCol(openFilterCol === col ? null : col);
@@ -265,6 +303,7 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
{openFilterCol === col && (
<ColumnFilterPopover
triggerId={`details-filter-trigger-${col}`}
uniqueValues={getUniqueValues(col)}
selectedValues={columnFilters[col] || []}
onToggle={(val) => toggleColumnFilter(col, val)}
@@ -293,11 +332,15 @@ export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProp
key={index}
className={cn(
"hover:bg-slate-700/20 transition-colors",
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
""
)}
>
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>
<div className="flex flex-col gap-1">
<span>{row[COLUMNS.ARTICLE_NO]}</span>
{saveStatus && <SyncStatusPill status={saveStatus} className="self-start" />}
</div>
</td>
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
{row[COLUMNS.ARTICLE_NAME]}
</td>
+404 -88
View File
@@ -1,7 +1,16 @@
import React, { useState, useMemo } from 'react';
import { Search, Check, X } from 'lucide-react';
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { Search, Check, X, Filter } from 'lucide-react';
import { cn } from '../lib/utils';
type FilterType = 'equals' | 'notEquals' | 'contains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'lessThan' | 'between';
interface FilterCondition {
type: FilterType;
value: string;
value2?: string;
}
interface ColumnFilterPopoverProps {
uniqueValues: string[];
selectedValues: string[];
@@ -11,6 +20,8 @@ interface ColumnFilterPopoverProps {
onClose: () => void;
title?: string;
className?: string;
zIndex?: number;
triggerId?: string;
}
export function ColumnFilterPopover({
@@ -21,102 +32,407 @@ export function ColumnFilterPopover({
onClear,
onClose,
title,
className
className,
zIndex = 50,
triggerId
}: ColumnFilterPopoverProps) {
const [search, setSearch] = useState('');
const [activeTab, setActiveTab] = useState<'values' | 'condition'>('values');
const [condition, setCondition] = useState<FilterCondition>({ type: 'equals', value: '' });
const [conditionResult, setConditionResult] = useState<string[]>([]);
const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
const [position, setPosition] = useState({ top: 0, left: 0 });
const updatePosition = useCallback(() => {
if (!triggerId) return;
const trigger = document.getElementById(triggerId);
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
setPosition({
top: rect.bottom + window.scrollY + 4,
left: Math.min(rect.left + window.scrollX, window.innerWidth + window.scrollX - 300)
});
}, [triggerId]);
useEffect(() => {
const container = document.createElement('div');
container.id = 'filter-portal-' + Math.random().toString(36).substring(2, 9);
container.style.position = 'absolute';
container.style.zIndex = '9999';
container.style.top = '0';
container.style.left = '0';
container.style.width = '100%';
container.style.pointerEvents = 'none';
document.body.appendChild(container);
setPortalContainer(container);
updatePosition();
// Update on scroll and resize
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
if (document.body.contains(container)) {
document.body.removeChild(container);
}
};
}, [updatePosition]);
const isDraggingRef = React.useRef(false);
const dragStartRef = React.useRef<number | null>(null);
const safeUniqueValues = uniqueValues || [];
const safeSelectedValues = selectedValues || [];
const hoveredIndexRef = React.useRef<number | null>(null);
const selectedValuesRef = React.useRef<string[]>(safeSelectedValues);
const onSelectAllRef = React.useRef(onSelectAll);
const filteredValuesRef = React.useRef<string[]>([]);
const didDragRef = React.useRef(false);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<number | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const filteredValues = useMemo(() => {
return uniqueValues.filter(v =>
return safeUniqueValues.filter(v =>
String(v || '').toLowerCase().includes(search.toLowerCase())
);
}, [uniqueValues, search]);
}, [safeUniqueValues, search]);
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
filteredValuesRef.current = filteredValues;
selectedValuesRef.current = safeSelectedValues;
onSelectAllRef.current = onSelectAll;
const isAllSelected = safeSelectedValues.length === safeUniqueValues.length && safeUniqueValues.length > 0;
const applyCondition = () => {
const results: string[] = [];
const valNum = parseFloat(condition.value);
const val2Num = condition.value2 ? parseFloat(condition.value2) : 0;
filteredValues.forEach(v => {
const strVal = String(v || '');
const numVal = parseFloat(v);
let matches = false;
switch (condition.type) {
case 'equals':
matches = strVal.toLowerCase() === condition.value.toLowerCase();
break;
case 'notEquals':
matches = strVal.toLowerCase() !== condition.value.toLowerCase();
break;
case 'contains':
matches = strVal.toLowerCase().includes(condition.value.toLowerCase());
break;
case 'startsWith':
matches = strVal.toLowerCase().startsWith(condition.value.toLowerCase());
break;
case 'endsWith':
matches = strVal.toLowerCase().endsWith(condition.value.toLowerCase());
break;
case 'greaterThan':
matches = !isNaN(numVal) && !isNaN(valNum) && numVal > valNum;
break;
case 'lessThan':
matches = !isNaN(numVal) && !isNaN(valNum) && numVal < valNum;
break;
case 'between':
matches = !isNaN(numVal) && !isNaN(val2Num) && numVal >= valNum && numVal <= val2Num;
break;
}
if (matches) results.push(v);
});
setConditionResult(results);
onSelectAll(results);
setActiveTab('values');
onClose();
};
const handleMouseDown = (index: number) => {
isDraggingRef.current = true;
dragStartRef.current = index;
hoveredIndexRef.current = index;
didDragRef.current = false;
setIsDragging(true);
setDragStart(index);
setHoveredIndex(index);
};
const handleMouseEnter = (index: number) => {
if (isDraggingRef.current && dragStartRef.current !== null) {
hoveredIndexRef.current = index;
setHoveredIndex(index);
}
};
React.useEffect(() => {
const handleGlobalMouseUp = () => {
if (isDraggingRef.current) {
const start = dragStartRef.current;
const end = hoveredIndexRef.current;
if (start !== null && end !== null && start !== end) {
const s = Math.min(start, end);
const e = Math.max(start, end);
const itemsToSelect = filteredValuesRef.current.slice(s, e + 1);
const newSelected = new Set([...selectedValuesRef.current]);
itemsToSelect.forEach(v => newSelected.add(v));
onSelectAllRef.current(Array.from(newSelected));
didDragRef.current = true;
setTimeout(() => { didDragRef.current = false; }, 100);
}
isDraggingRef.current = false;
dragStartRef.current = null;
hoveredIndexRef.current = null;
setIsDragging(false);
setDragStart(null);
setHoveredIndex(null);
}
};
document.addEventListener('mouseup', handleGlobalMouseUp);
return () => document.removeEventListener('mouseup', handleGlobalMouseUp);
}, []);
return (
<div
className={cn(
"absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100",
className
)}
onClick={(e) => e.stopPropagation()}
>
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Filter values..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500"
autoFocus
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
{filteredValues.map(val => (
<div
key={val}
role="checkbox"
aria-checked={selectedValues.includes(val)}
tabIndex={0}
onClick={() => onToggle(val)}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); } }}
className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group"
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
</div>
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</div>
))}
{filteredValues.length === 0 && (
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
<div className="flex items-center gap-3">
<button
onClick={() => {
if (isAllSelected) {
onSelectAll([]);
} else {
onSelectAll(uniqueValues);
}
}}
className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight"
>
{isAllSelected ? 'Deselect All' : 'Select All'}
</button>
<span className="text-slate-600 font-bold"></span>
<button
onClick={onClear}
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
>
Clear Current
</button>
</div>
<button
onClick={onClose}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg active:scale-95 uppercase"
<div className="fixed inset-0 z-0 pointer-events-none">
{portalContainer && createPortal(
<div
className={cn(
"absolute w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100 pointer-events-auto",
className
)}
style={{
zIndex: 9999,
top: position.top,
left: position.left
}}
onClick={(e) => e.stopPropagation()}
>
OK
</button>
</div>
<button
onClick={onClose}
className="absolute top-2 right-2 text-slate-500 hover:text-white p-1 rounded transition-colors z-10"
>
<X className="w-4 h-4" />
</button>
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
<div className="flex gap-1 border-b border-slate-700 pb-2">
<button
onClick={() => setActiveTab('values')}
className={cn(
"flex-1 px-2 py-1 text-[10px] font-bold rounded transition-colors",
activeTab === 'values' ? "bg-blue-600 text-white" : "text-slate-400 hover:text-white"
)}
>
Values ({safeSelectedValues.length})
</button>
<button
onClick={() => setActiveTab('condition')}
className={cn(
"flex-1 px-2 py-1 text-[10px] font-bold rounded transition-colors flex items-center justify-center gap-1",
activeTab === 'condition' ? "bg-blue-600 text-white" : "text-slate-400 hover:text-white"
)}
>
<Filter className="w-3 h-3" />
Condition
</button>
</div>
{activeTab === 'values' ? (
<>
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Filter values..."
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (search) {
onSelectAll(filteredValues);
}
onClose();
}
}}
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500"
autoFocus
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<div
ref={listRef}
className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar"
>
{filteredValues.map((val, idx) => {
const isDragSelected = isDragging && dragStart !== null && hoveredIndex !== null &&
((idx >= dragStart && idx <= hoveredIndex) || (idx <= dragStart && idx >= hoveredIndex));
return (
<div
key={val}
role="checkbox"
aria-checked={safeSelectedValues.includes(val)}
tabIndex={0}
onMouseDown={(e) => { e.preventDefault(); handleMouseDown(idx); }}
onMouseEnter={() => handleMouseEnter(idx)}
onClick={(e) => {
if (didDragRef.current) return;
if (e.shiftKey && lastClickedIndex !== null) {
const start = Math.min(lastClickedIndex, idx);
const end = Math.max(lastClickedIndex, idx);
const itemsToSelect = filteredValues.slice(start, end + 1);
const newSelected = new Set([...safeSelectedValues, ...itemsToSelect]);
onSelectAll(Array.from(newSelected));
} else {
onToggle(val);
}
setLastClickedIndex(idx);
}}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); setLastClickedIndex(idx); } }}
className={cn(
"flex items-center gap-2 p-1.5 rounded cursor-pointer group transition-colors select-none",
isDragSelected ? "bg-blue-600/40" : "hover:bg-slate-700/50"
)}
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{safeSelectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
</div>
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</div>
);
})}
{filteredValues.length === 0 && (
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
<div className="flex items-center gap-3">
<button
onClick={() => {
if (isAllSelected) {
onSelectAll([]);
} else {
onSelectAll(safeUniqueValues);
}
}}
className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight"
>
{isAllSelected ? 'Deselect All' : 'Select All'}
</button>
<span className="text-slate-600 font-bold"></span>
<button
onClick={onClear}
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
>
Clear
</button>
</div>
<button
onClick={onClose}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg active:scale-95 uppercase"
>
OK
</button>
</div>
</>
) : (
<>
<div className="space-y-2">
<div>
<label className="text-[10px] text-slate-400 uppercase">Condition Type</label>
<select
value={condition.type}
onChange={e => setCondition({ ...condition, type: e.target.value as FilterType })}
className="w-full mt-1 bg-slate-900 border border-slate-700 rounded p-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
>
<option value="equals">Equals (=)</option>
<option value="notEquals">Not Equals ()</option>
<option value="contains">Contains</option>
<option value="startsWith">Starts With</option>
<option value="endsWith">Ends With</option>
<option value="greaterThan">Greater Than (&gt;)</option>
<option value="lessThan">Less Than (&lt;)</option>
<option value="between">Between</option>
</select>
</div>
<div>
<label className="text-[10px] text-slate-400 uppercase">Value</label>
<input
type="text"
value={condition.value}
onChange={e => setCondition({ ...condition, value: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyCondition();
}
}}
placeholder="Enter value..."
className="w-full mt-1 bg-slate-900 border border-slate-700 rounded p-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
/>
</div>
{condition.type === 'between' && (
<div>
<label className="text-[10px] text-slate-400 uppercase">And</label>
<input
type="text"
value={condition.value2 || ''}
onChange={e => setCondition({ ...condition, value2: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
applyCondition();
}
}}
placeholder="Enter second value..."
className="w-full mt-1 bg-slate-900 border border-slate-700 rounded p-1.5 text-xs text-white focus:outline-none focus:border-blue-500"
/>
</div>
)}
<button
onClick={applyCondition}
className="w-full py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded transition-colors"
>
Apply Condition
</button>
</div>
{conditionResult.length > 0 && (
<div className="text-[10px] text-green-400 text-center pt-2 border-t border-slate-700">
Found {conditionResult.length} matching value{conditionResult.length !== 1 ? 's' : ''}
</div>
)}
</>
)}
</div>,
portalContainer
)}
</div>
);
}
+474
View File
@@ -0,0 +1,474 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ArrowDownRight, ArrowUpRight, LayoutDashboard, RefreshCw } from 'lucide-react';
import { ExcelRow } from '../types';
import { cn } from '../lib/utils';
import {
ArticleDetailsDashboardSnapshot,
CosmeticDashboardSnapshot,
DashboardDrilldownRequest,
DescriptionsDashboardSnapshot,
PendingRowInfo,
SnapshotStore,
computeArticleDetailsDashboardSnapshot,
computeCosmeticDashboardSnapshot,
computeDescriptionsDashboardSnapshot,
computePricingDashboardSnapshot,
ensureDailyDashboardSnapshot,
getDaysAgoKey,
loadDashboardSnapshots,
} from '../lib/controlDashboard';
interface ControlDashboardViewProps {
headers: string[];
data: ExcelRow[];
pendingRows: Record<string, PendingRowInfo>;
rowStatuses: Record<string, string>;
activeModule?: string;
onOpenTab?: (tabId: string) => void;
onDrillDown?: (request: DashboardDrilldownRequest) => void;
}
type MetricKey = keyof Pick<DescriptionsDashboardSnapshot, 'ok' | 'longDeMissing' | 'longEnMissing' | 'shortDeMissing' | 'shortEnMissing'>;
type ArticleMetricKey = keyof Pick<ArticleDetailsDashboardSnapshot, 'ok' | 'detailsDeMissing' | 'detailsEnMissing'>;
type CosmeticMetricKey = keyof Pick<CosmeticDashboardSnapshot, 'ok' | 'cpnpMissing'>;
type PricingMetricKey = 'ok' | 'itemToLogisticMissing' | 'uvpMissing' | 'srpIntMissing' | 'srpUkMissing' | 'unitsOuterMissing' | 'outerWMissing' | 'outerLMissing' | 'outerHMissing' | 'units40fMissing' | 'moqMissing' | 'weightIssues';
type SnapshotKey = MetricKey | ArticleMetricKey | CosmeticMetricKey | PricingMetricKey;
const METRICS: Array<{
key: SnapshotKey;
label: string;
toneClass: string;
positiveIsGood: boolean;
drilldownFocus: string;
}> = [
{ key: 'ok', label: 'All OK', toneClass: 'border-emerald-400/30 bg-emerald-400/15 text-emerald-100', positiveIsGood: true, drilldownFocus: 'complete' },
{ key: 'longDeMissing', label: 'Missing Long DE', toneClass: 'border-sky-400/30 bg-sky-400/15 text-sky-100', positiveIsGood: false, drilldownFocus: 'missingLongDE' },
{ key: 'longEnMissing', label: 'Missing Long EN', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: false, drilldownFocus: 'missingLongEN' },
{ key: 'shortDeMissing', label: 'Missing Short DE', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: false, drilldownFocus: 'missingShortDE' },
{ key: 'shortEnMissing', label: 'Missing Short EN', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'missingShortEN' },
];
const ARTICLE_METRICS: Array<{
key: ArticleMetricKey;
label: string;
toneClass: string;
positiveIsGood: boolean;
drilldownFocus: string;
}> = [
{ key: 'ok', label: 'All OK', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: true, drilldownFocus: 'all' },
{ key: 'detailsDeMissing', label: 'Missing Details DE', toneClass: 'border-cyan-400/30 bg-cyan-400/15 text-cyan-100', positiveIsGood: false, drilldownFocus: 'missingDetailsDE' },
{ key: 'detailsEnMissing', label: 'Missing Details EN', toneClass: 'border-amber-400/30 bg-amber-400/15 text-amber-100', positiveIsGood: false, drilldownFocus: 'missingDetailsEN' },
];
const PRICING_METRICS: Array<{
key: PricingMetricKey;
label: string;
toneClass: string;
positiveIsGood: boolean;
drilldownFocus: string;
}> = [
{ key: 'ok', label: 'All OK', toneClass: 'border-amber-400/30 bg-amber-400/15 text-amber-100', positiveIsGood: true, drilldownFocus: 'all' },
{ key: 'itemToLogisticMissing', label: 'Missing Item to Logistic', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: false, drilldownFocus: 'itemToLogisticMissing' },
{ key: 'uvpMissing', label: 'Missing UVP (€)', toneClass: 'border-emerald-400/30 bg-emerald-400/15 text-emerald-100', positiveIsGood: false, drilldownFocus: 'uvpMissing' },
{ key: 'srpIntMissing', label: 'Missing SRP INT', toneClass: 'border-indigo-400/30 bg-indigo-400/15 text-indigo-100', positiveIsGood: false, drilldownFocus: 'srpIntMissing' },
{ key: 'srpUkMissing', label: 'Missing SRP UK (£)', toneClass: 'border-cyan-400/30 bg-cyan-400/15 text-cyan-100', positiveIsGood: false, drilldownFocus: 'srpUkMissing' },
{ key: 'unitsOuterMissing', label: 'Missing Units/Outer', toneClass: 'border-sky-400/30 bg-sky-400/15 text-sky-100', positiveIsGood: false, drilldownFocus: 'unitsOuterMissing' },
{ key: 'outerWMissing', label: 'Missing Outer W', toneClass: 'border-violet-400/30 bg-violet-400/15 text-violet-100', positiveIsGood: false, drilldownFocus: 'outerWMissing' },
{ key: 'outerLMissing', label: 'Missing Outer L', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'outerLMissing' },
{ key: 'outerHMissing', label: 'Missing Outer H', toneClass: 'border-pink-400/30 bg-pink-400/15 text-pink-100', positiveIsGood: false, drilldownFocus: 'outerHMissing' },
{ key: 'units40fMissing', label: 'Missing Units 40F', toneClass: 'border-teal-400/30 bg-teal-400/15 text-teal-100', positiveIsGood: false, drilldownFocus: 'units40fMissing' },
{ key: 'moqMissing', label: 'Missing MOQ', toneClass: 'border-orange-400/30 bg-orange-400/15 text-orange-100', positiveIsGood: false, drilldownFocus: 'moqMissing' },
{ key: 'weightIssues', label: 'Weight Issues', toneClass: 'border-red-400/30 bg-red-400/15 text-red-100', positiveIsGood: false, drilldownFocus: 'weightIssues' },
];
const COSMETIC_METRICS: Array<{
key: CosmeticMetricKey;
label: string;
toneClass: string;
positiveIsGood: boolean;
drilldownFocus: string;
}> = [
{ key: 'ok', label: 'CPNP present', toneClass: 'border-fuchsia-400/30 bg-fuchsia-400/15 text-fuchsia-100', positiveIsGood: true, drilldownFocus: 'present' },
{ key: 'cpnpMissing', label: 'CPNP missing', toneClass: 'border-rose-400/30 bg-rose-400/15 text-rose-100', positiveIsGood: false, drilldownFocus: 'missing' },
];
function formatNumber(value: number): string {
return new Intl.NumberFormat('en-GB').format(value);
}
function formatDelta(current: number, historical: number, positiveIsGood: boolean): { text: string; className: string } {
const delta = current - historical;
if (delta === 0) {
return { text: '0 change', className: 'text-slate-400' };
}
if (positiveIsGood) {
return delta > 0
? { text: `${delta} improved`, className: 'text-emerald-400' }
: { text: `${Math.abs(delta)} worse`, className: 'text-rose-400' };
}
return delta < 0
? { text: `${Math.abs(delta)} resolved`, className: 'text-emerald-400' }
: { text: `${delta} new`, className: 'text-rose-400' };
}
function MetricTile({
label,
current,
historical,
toneClass,
positiveIsGood,
onClick,
}: {
label: string;
current: number;
historical?: number;
toneClass: string;
positiveIsGood: boolean;
onClick?: () => void;
}) {
const delta = historical === undefined ? null : formatDelta(current, historical, positiveIsGood);
const tileClassName = cn(
'rounded-2xl border p-3 shadow-inner shadow-black/20 transition-all duration-200 bg-slate-950/70 text-left',
onClick && 'cursor-pointer hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-300/70',
toneClass
);
return (
onClick ? (
<button type="button" onClick={onClick} className={tileClassName}>
<p className="text-[10px] uppercase tracking-[0.16em] leading-none text-slate-400">{label}</p>
<div className="mt-2.5 flex items-end justify-between gap-2.5">
<div className="text-2xl font-semibold text-white tabular-nums leading-none">
{formatNumber(current)}
</div>
{historical === undefined ? (
<div className="text-right text-[10px] text-slate-500 leading-tight">
No historical data available
</div>
) : (
<div className="text-right">
<div className="text-[10px] uppercase tracking-wide text-slate-500">7 days ago</div>
<div className="text-xs font-medium text-slate-300 tabular-nums">
{formatNumber(historical)}
</div>
</div>
)}
</div>
{delta && (
<div className={cn('mt-1.5 text-[11px] font-semibold', delta.className)}>
{delta.text}
</div>
)}
</button>
) : (
<div className={tileClassName}>
<p className="text-[10px] uppercase tracking-[0.16em] leading-none text-slate-400">{label}</p>
<div className="mt-2.5 flex items-end justify-between gap-2.5">
<div className="text-2xl font-semibold text-white tabular-nums leading-none">
{formatNumber(current)}
</div>
{historical === undefined ? (
<div className="text-right text-[10px] text-slate-500 leading-tight">
No historical data available
</div>
) : (
<div className="text-right">
<div className="text-[10px] uppercase tracking-wide text-slate-500">7 days ago</div>
<div className="text-xs font-medium text-slate-300 tabular-nums">
{formatNumber(historical)}
</div>
</div>
)}
</div>
{delta && (
<div className={cn('mt-1.5 text-[11px] font-semibold', delta.className)}>
{delta.text}
</div>
)}
</div>
)
);
}
type DashboardSnapshotLike = {
total: number;
ok: number;
[key: string]: number;
};
function DashboardCard({
title,
subtitle,
accentClass,
accentBarClass,
badgeClass,
badgeLabel,
titleClass,
current,
historical,
metrics,
metricGridClassName,
tabId,
onDrillDown,
}: {
title: string;
subtitle: string;
accentClass: string;
accentBarClass: string;
badgeClass: string;
badgeLabel: string;
titleClass: string;
current: DashboardSnapshotLike;
historical?: DashboardSnapshotLike;
metricGridClassName?: string;
metrics: Array<{
key: SnapshotKey;
label: string;
toneClass: string;
positiveIsGood: boolean;
drilldownFocus: string;
}>;
tabId: DashboardDrilldownRequest['tabId'];
onDrillDown?: (request: DashboardDrilldownRequest) => void;
}) {
return (
<section className={cn(
'rounded-3xl border bg-gradient-to-br from-slate-900 via-slate-950 to-black shadow-2xl backdrop-blur-sm overflow-hidden',
accentClass
)}>
<div className={cn('h-1 w-full opacity-100 shadow-[0_0_18px_rgba(255,255,255,0.18)]', accentBarClass)} />
<div className="flex items-start justify-between gap-4 border-b border-slate-700/80 bg-black/20 px-4 py-3.5">
<div>
<div className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.22em]', badgeClass)}>
{badgeLabel}
</div>
<h2 className={cn('mt-2 text-base font-semibold', titleClass)}>{title}</h2>
<p className="mt-1 text-[11px] text-slate-300">{subtitle}</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<span className="rounded-full border border-slate-600/70 bg-slate-800/80 px-2.5 py-1 text-[10px] font-medium text-slate-200">
Total {formatNumber(current.total)}
</span>
<span className="rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2.5 py-1 text-[10px] font-semibold text-emerald-200">
All OK {formatNumber(current.ok)}
</span>
</div>
</div>
<div className={cn('grid gap-2.5 px-4 py-4 sm:grid-cols-2 lg:grid-cols-3', metricGridClassName)}>
{metrics.map(metric => (
<React.Fragment key={metric.key}>
<MetricTile
label={metric.label}
current={current[metric.key]}
historical={historical?.[metric.key]}
toneClass={metric.toneClass}
positiveIsGood={metric.positiveIsGood}
onClick={onDrillDown ? () => onDrillDown({
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
tabId: tabId as DashboardDrilldownRequest['tabId'],
focus: metric.drilldownFocus,
}) : undefined}
/>
</React.Fragment>
))}
</div>
<div className="border-t border-slate-700/70 bg-black/20 px-4 py-3 text-[11px] text-slate-300">
{historical ? (
<div className="flex flex-wrap items-center gap-3">
<span className="inline-flex items-center gap-1">
<ArrowUpRight className="h-3 w-3 text-emerald-400" />
Improvements are shown in green
</span>
<span className="inline-flex items-center gap-1">
<ArrowDownRight className="h-3 w-3 text-rose-400" />
Regressions are shown in red
</span>
</div>
) : (
<div>No historical data available</div>
)}
</div>
</section>
);
}
export function ControlDashboardView({
headers,
data,
pendingRows,
rowStatuses,
onDrillDown,
}: ControlDashboardViewProps) {
const [dashboardSnapshots, setDashboardSnapshots] = useState<SnapshotStore>({});
const [snapshotsLoading, setSnapshotsLoading] = useState(true);
const currentSnapshot = useMemo(() => {
return computeDescriptionsDashboardSnapshot(headers, {
data,
pendingRows,
rowStatuses,
historyEntries: [],
});
}, [headers, data, pendingRows, rowStatuses]);
const currentArticleSnapshot = useMemo(() => {
return computeArticleDetailsDashboardSnapshot(headers, {
data,
pendingRows,
rowStatuses,
historyEntries: [],
});
}, [headers, data, pendingRows, rowStatuses]);
const currentPricingSnapshot = useMemo(() => {
return computePricingDashboardSnapshot(headers, {
data,
pendingRows,
rowStatuses,
historyEntries: [],
});
}, [headers, data, pendingRows, rowStatuses]);
const currentCosmeticSnapshot = useMemo(() => {
return computeCosmeticDashboardSnapshot(headers, {
data,
pendingRows,
rowStatuses,
historyEntries: [],
});
}, [headers, data, pendingRows, rowStatuses]);
useEffect(() => {
let cancelled = false;
const loadSnapshots = async () => {
setSnapshotsLoading(true);
try {
const store = await loadDashboardSnapshots();
if (!cancelled) {
setDashboardSnapshots(store);
}
} finally {
if (!cancelled) {
setSnapshotsLoading(false);
}
}
};
loadSnapshots();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (snapshotsLoading) return;
void ensureDailyDashboardSnapshot(new Date(), {
descriptions: currentSnapshot,
articleDetails: currentArticleSnapshot,
pricing: currentPricingSnapshot,
cosmeticItems: currentCosmeticSnapshot,
});
}, [snapshotsLoading, currentSnapshot, currentArticleSnapshot, currentPricingSnapshot, currentCosmeticSnapshot]);
const historicalSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.descriptions;
const historicalArticleSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.articleDetails;
const historicalPricingSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.pricing;
const historicalCosmeticSnapshot = dashboardSnapshots[getDaysAgoKey(7)]?.cosmeticItems;
return (
<div className="grid gap-4 xl:grid-cols-2">
<div className="xl:col-span-2 flex items-center justify-between gap-4 rounded-3xl border border-slate-700/60 bg-slate-900/80 px-4 py-3.5 shadow-xl">
<div>
<div className="flex items-center gap-2 text-slate-300">
<LayoutDashboard className="h-4 w-4 text-blue-400" />
<h1 className="text-lg font-semibold text-white">Control Dashboard</h1>
</div>
<p className="mt-1 text-xs text-slate-400">
Compact overview of Product Descriptions, Article Details and Pricing & Units.
</p>
</div>
<button
onClick={() => {
if (typeof window !== 'undefined') {
window.location.reload();
}
}}
className="inline-flex items-center gap-2 rounded-full border border-slate-600/70 bg-slate-800/80 px-3 py-2 text-[11px] font-medium text-slate-200 hover:border-slate-500 hover:text-white transition-colors"
>
<RefreshCw className="h-3 w-3" />
Refresh
</button>
</div>
<DashboardCard
title="Product Descriptions"
subtitle="Long DE, Long EN, Short DE and Short EN."
accentClass="border-emerald-400/40 bg-emerald-500/8 shadow-[0_0_0_1px_rgba(52,211,153,0.12)]"
accentBarClass="bg-gradient-to-r from-emerald-300 via-lime-300 to-cyan-300"
badgeClass="bg-emerald-500/20 text-emerald-100 ring-1 ring-emerald-300/30"
badgeLabel="Descriptions"
titleClass="text-emerald-100"
current={currentSnapshot}
historical={historicalSnapshot}
metrics={METRICS}
metricGridClassName="sm:grid-cols-2 lg:grid-cols-3"
tabId="descriptions"
onDrillDown={onDrillDown}
/>
<DashboardCard
title="Article Details"
subtitle="DETAILS DE and DETAILS EN."
accentClass="border-indigo-400/40 bg-indigo-500/12 shadow-[0_0_0_1px_rgba(129,140,248,0.12)]"
accentBarClass="bg-gradient-to-r from-indigo-300 via-violet-300 to-cyan-300"
badgeClass="bg-indigo-500/20 text-indigo-100 ring-1 ring-indigo-300/30"
badgeLabel="Details"
titleClass="text-indigo-100"
current={currentArticleSnapshot}
historical={historicalArticleSnapshot}
metrics={ARTICLE_METRICS}
metricGridClassName="sm:grid-cols-2 lg:grid-cols-3"
tabId="article_details"
onDrillDown={onDrillDown}
/>
<DashboardCard
title="Pricing & Units"
subtitle="Item to Logistic, pricing, Units 40F and Weight Issues."
accentClass="border-amber-400/40 bg-amber-500/12 shadow-[0_0_0_1px_rgba(251,191,36,0.12)]"
accentBarClass="bg-gradient-to-r from-amber-300 via-orange-300 to-red-300"
badgeClass="bg-amber-500/20 text-amber-100 ring-1 ring-amber-300/30"
badgeLabel="Pricing"
titleClass="text-amber-100"
current={currentPricingSnapshot}
historical={historicalPricingSnapshot}
metrics={PRICING_METRICS}
metricGridClassName="sm:grid-cols-2 xl:grid-cols-4"
tabId="pricing"
onDrillDown={onDrillDown}
/>
<DashboardCard
title="Cosmetic Items"
subtitle="CPNP present vs missing, with 7-day evolution."
accentClass="border-fuchsia-400/40 bg-fuchsia-500/12 shadow-[0_0_0_1px_rgba(232,121,249,0.12)]"
accentBarClass="bg-gradient-to-r from-fuchsia-300 via-pink-300 to-rose-300"
badgeClass="bg-fuchsia-500/20 text-fuchsia-100 ring-1 ring-fuchsia-300/30"
badgeLabel="Cosmetic"
titleClass="text-fuchsia-100"
current={currentCosmeticSnapshot}
historical={historicalCosmeticSnapshot}
metrics={COSMETIC_METRICS}
metricGridClassName="grid-cols-1 sm:grid-cols-2"
tabId="cosmetic_items"
onDrillDown={onDrillDown}
/>
</div>
);
}
+445
View File
@@ -0,0 +1,445 @@
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { Search, ChevronDown, ChevronUp, X, Save, Check, Loader2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { saveRowToSupabase } from '../lib/supabase';
import { SyncStatusPill } from './SyncStatusPill';
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
const COSMETIC_LINES = ['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS'];
interface CosmeticItemsViewProps {
data: ExcelRow[];
headers: string[];
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
onCaptureState: (message: string) => void;
rowStatuses: Record<string, string>;
onQueueBcSync: (articleNo: string, rowIndex: number, originalData: ExcelRow, newData: ExcelRow, articleName: string) => void;
dashboardDrilldown?: DashboardDrilldownRequest | null;
onDashboardDrilldownApplied?: () => void;
}
export function CosmeticItemsView({ data, headers, onSaveRow, onCaptureState, rowStatuses, onQueueBcSync, dashboardDrilldown, onDashboardDrilldownApplied }: CosmeticItemsViewProps) {
const COLUMNS = useColumns();
const [search, setSearch] = usePersistentState('cosmeticItems-search', '');
const [sortCol, setSortCol] = usePersistentState<number | null>('cosmeticItems-sortCol', null);
const [sortDesc, setSortDesc] = usePersistentState('cosmeticItems-sortDesc', false);
const [cpnpFilter, setCpnpFilter] = usePersistentState<'all' | 'present' | 'missing'>('cosmeticItems-cpnpFilter', 'all');
const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('cosmeticItems-columnFilters', {});
const [openFilter, setOpenFilter] = useState<number | null>(null);
const [editingCpnp, setEditingCpnp] = useState<{ rowIndex: number; value: string } | null>(null);
const [savingCpnp, setSavingCpnp] = useState<number | null>(null);
const cpnpInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingCpnp !== null) cpnpInputRef.current?.focus();
}, [editingCpnp]);
useEffect(() => {
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'cosmetic_items') return;
const focus = dashboardDrilldown.focus === 'missing' ? 'missing' : 'present';
setCpnpFilter(focus);
setSearch('');
setColumnFilters({});
setPage(1);
onDashboardDrilldownApplied?.();
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied, setSearch, setColumnFilters, setCpnpFilter]);
const pageSize = 100;
const columns = [
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 110 },
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 240 },
{ col: COLUMNS.LINE, label: 'Line', width: 120 },
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Item Available', width: 120 },
{ col: COLUMNS.CPNP_NO, label: 'CPNP No.', width: 160 },
];
const columnUniqueValues = useMemo(() => {
const result: Record<number, Set<string>> = {};
columns.forEach(c => { result[c.col] = new Set(); });
data.forEach(row => {
const lineVal = String(row[COLUMNS.LINE] ?? '').trim().toUpperCase();
if (!COSMETIC_LINES.some(l => lineVal === l)) return;
columns.forEach(({ col }) => {
result[col].add(String(row[col] ?? '').replace(/\s+/g, ' ').trim());
});
});
return result;
}, [data, COLUMNS]);
const getUniqueValues = (col: number): string[] =>
Array.from(columnUniqueValues[col] as Set<string> ?? []).sort((a, b) => {
const na = parseFloat(a), nb = parseFloat(b);
if (!isNaN(na) && !isNaN(nb)) return na - nb;
return a.localeCompare(b);
});
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
// Filter: only cosmetic lines
result = result.filter(({ row }) => {
const lineVal = String(row[COLUMNS.LINE] ?? '').trim().toUpperCase();
return COSMETIC_LINES.some(l => lineVal === l);
});
if (cpnpFilter !== 'all') {
result = result.filter(({ row }) => {
const cpnp = String(row[COLUMNS.CPNP_NO] ?? '').trim();
return cpnpFilter === 'present' ? cpnp !== '' : cpnp === '';
});
}
// Global search
if (search) {
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
result = result.filter(({ row }) => {
const articleNo = String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
const articleName = String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
return terms.every(t => articleNo.includes(t) || articleName.includes(t));
});
}
// Column filters
(Object.entries(columnFilters) as [string, string[]][]).forEach(([colIdx, filterValues]) => {
if (!filterValues || filterValues.length === 0) return;
const colNum = parseInt(colIdx);
const knownCount = columnUniqueValues[colNum]?.size ?? 0;
// All known values selected → no filtering needed
if (filterValues.length >= knownCount) return;
const normalize = (v: any) => String(v ?? '').replace(/\s+/g, ' ').trim();
const filterSet = new Set(filterValues.map(normalize));
result = result.filter(({ row }) => filterSet.has(normalize(row[colNum])));
});
// Sort
if (sortCol !== null) {
result.sort((a, b) => {
const valA = a.row[sortCol];
const valB = b.row[sortCol];
if (typeof valA === 'number' && typeof valB === 'number') {
return sortDesc ? valB - valA : valA - valB;
}
const sA = String(valA ?? '');
const sB = String(valB ?? '');
return sortDesc ? sB.localeCompare(sA) : sA.localeCompare(sB);
});
}
return result;
}, [data, search, sortCol, sortDesc, columnFilters, COLUMNS, columnUniqueValues, cpnpFilter]);
const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredData.slice(start, start + pageSize);
}, [filteredData, page]);
const totalPages = Math.ceil(filteredData.length / pageSize);
const handleSort = (col: number) => {
if (sortCol === col) setSortDesc(d => !d);
else { setSortCol(col); setSortDesc(false); }
};
const startEditCpnp = (rowIndex: number, currentValue: any) => {
setEditingCpnp({ rowIndex, value: String(currentValue ?? '') });
};
const saveCpnp = async (rowIndex: number) => {
if (!editingCpnp || editingCpnp.rowIndex !== rowIndex) return;
const row = data[rowIndex];
const articleNo = String(row[COLUMNS.ARTICLE_NO]);
const cpnpValue = editingCpnp.value.trim();
const newRow = [...row];
newRow[COLUMNS.CPNP_NO] = cpnpValue;
setSavingCpnp(rowIndex);
setEditingCpnp(null);
onCaptureState(`Updated CPNP No. for ${articleNo}`);
onSaveRow(rowIndex, newRow);
const supabaseResult = await saveRowToSupabase(articleNo, newRow, 'edited');
if (supabaseResult.success) {
onQueueBcSync(articleNo, rowIndex, row, newRow, String(row[COLUMNS.ARTICLE_NAME] || articleNo));
}
setSavingCpnp(null);
const errors: string[] = [];
if (!supabaseResult.success) errors.push(`Supabase: ${supabaseResult.error}`);
if (errors.length > 0) {
alert(`Error saving CPNP No. for ${articleNo}:\n${errors.join('\n')}`);
}
};
const cancelEditCpnp = () => setEditingCpnp(null);
const lineBadgeColor = (line: string) => {
const l = String(line).toUpperCase();
if (l === 'INKEE') return 'bg-pink-500/10 text-pink-400 border-pink-500/20';
if (l === 'BATH FUN') return 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20';
if (l === 'TOP FASHION') return 'bg-purple-500/10 text-purple-400 border-purple-500/20';
if (l === 'SENSES') return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20';
return 'bg-slate-700/50 text-slate-400 border-slate-600/50';
};
return (
<div className="flex flex-col h-full">
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
<div className="flex-1 min-w-[200px] relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
type="text"
placeholder="Search SKU or Name..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-10 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
{search && (
<button
onClick={() => { setSearch(''); setPage(1); }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-3 text-xs text-slate-500">
<span className="rounded-full border border-fuchsia-500/20 bg-fuchsia-500/10 px-3 py-1 font-medium text-fuchsia-200">
Filtered {filteredData.length}
</span>
<span className="text-slate-600">·</span>
<span>Lines: {COSMETIC_LINES.join(', ')}</span>
</div>
<div className="flex items-center gap-2">
{[
{ id: 'all', label: 'All' },
{ id: 'present', label: 'With CPNP' },
{ id: 'missing', label: 'Missing CPNP' },
].map(option => (
<button
key={option.id}
onClick={() => { setCpnpFilter(option.id as 'all' | 'present' | 'missing'); setPage(1); }}
className={cn(
"rounded-md border px-3 py-2 text-xs font-medium transition-colors",
cpnpFilter === option.id
? "border-indigo-500 bg-indigo-500/15 text-indigo-200"
: "border-slate-700 bg-slate-900/50 text-slate-400 hover:border-slate-600 hover:text-slate-200"
)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<div className="overflow-x-auto flex-1">
<table className="w-full text-left text-xs">
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
<tr>
{columns.map(({ col, label, width }) => {
const selectedFilters = columnFilters[col] || [];
const filterCount = selectedFilters.length;
const isCpnp = col === COLUMNS.CPNP_NO;
return (
<th
key={col}
style={{ width, minWidth: width }}
className="px-2 py-2 font-medium border-r border-slate-700/30 relative"
>
<div
className="flex items-center gap-1 cursor-pointer select-none hover:text-white"
role="button"
tabIndex={0}
onClick={() => handleSort(col)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') handleSort(col); }}
>
{label}
{sortCol === col && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)}
{isCpnp && (
<span className="ml-1 text-[9px] text-indigo-400 font-normal">(editable)</span>
)}
</div>
<div className="mt-1 relative">
<button
id={`cosmetic-filter-trigger-${col}`}
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === col ? null : col); }}
className={cn(
"w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors",
filterCount > 0 ? "border-indigo-500 text-white" : "border-slate-600 text-slate-400 hover:border-slate-500"
)}
>
<span className="truncate">{filterCount > 0 ? `${filterCount} selected` : 'Filter...'}</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
</button>
{openFilter === col && (
<ColumnFilterPopover
triggerId={`cosmetic-filter-trigger-${col}`}
uniqueValues={getUniqueValues(col)}
selectedValues={selectedFilters}
onToggle={(val) => setColumnFilters(prev => {
const current = prev[col] || [];
if (current.includes(val)) {
return { ...prev, [col]: current.filter(v => v !== val) };
}
return { ...prev, [col]: [...current, val] };
})}
onSelectAll={(vals) => setColumnFilters(prev => ({ ...prev, [col]: vals }))}
onClear={() => { setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; }); }}
onClose={() => setOpenFilter(null)}
/>
)}
</div>
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{paginatedData.map(({ row, index }) => {
const articleNo = String(row[COLUMNS.ARTICLE_NO] ?? '');
const status = rowStatuses[articleNo];
const isEditing = editingCpnp?.rowIndex === index;
return (
<tr
key={articleNo || index}
className={cn(
"hover:bg-slate-700/20 transition-colors",
false
)}
>
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: 110 }}>
<div className="flex flex-col gap-1">
<span>{articleNo}</span>
{status && <SyncStatusPill status={status} className="self-start" />}
</div>
</td>
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 240 }} title={row[COLUMNS.ARTICLE_NAME]}>
{row[COLUMNS.ARTICLE_NAME]}
</td>
<td className="px-3 py-2" style={{ width: 120 }}>
<span className={cn(
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
lineBadgeColor(String(row[COLUMNS.LINE] ?? ''))
)}>
{row[COLUMNS.LINE]}
</span>
</td>
<td className="px-3 py-2 font-mono text-slate-300 text-right" style={{ width: 120 }}>
{row[COLUMNS.ITEM_AVAILABLE]}
</td>
<td className="px-3 py-2" style={{ width: 160 }}>
{savingCpnp === index ? (
<span className="flex items-center gap-1.5 text-[10px] text-slate-400">
<Loader2 className="w-3 h-3 animate-spin" />
Saving...
</span>
) : isEditing ? (
<div className="flex items-center gap-1">
<input
ref={cpnpInputRef}
type="text"
value={editingCpnp.value}
onChange={e => setEditingCpnp(prev => prev ? { ...prev, value: e.target.value } : prev)}
onKeyDown={e => {
if (e.key === 'Enter') saveCpnp(index);
if (e.key === 'Escape') cancelEditCpnp();
}}
className="flex-1 min-w-0 bg-slate-900 border border-indigo-500 rounded px-2 py-1 text-xs text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="Enter CPNP No."
/>
<button
onClick={() => saveCpnp(index)}
className="p-1 text-emerald-400 hover:text-emerald-300 hover:bg-emerald-400/10 rounded transition-colors"
title="Save"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={cancelEditCpnp}
className="p-1 text-slate-500 hover:text-slate-300 hover:bg-slate-700 rounded transition-colors"
title="Cancel"
>
<X className="w-4 h-4" />
</button>
</div>
) : (() => {
const cpnpVal = String(row[COLUMNS.CPNP_NO] ?? '').trim();
return cpnpVal ? (
<button
onClick={() => startEditCpnp(index, row[COLUMNS.CPNP_NO])}
className="w-full text-left px-2 py-1 rounded border border-slate-600 text-emerald-400 hover:border-indigo-500 hover:text-indigo-400 transition-colors text-[10px] font-mono"
title="Click to edit CPNP No."
>
{cpnpVal}
</button>
) : (
<button
onClick={() => startEditCpnp(index, row[COLUMNS.CPNP_NO])}
className="w-full text-left px-2 py-1 rounded border border-dashed border-slate-600 text-slate-500 hover:border-indigo-500 hover:text-indigo-400 transition-colors text-[10px]"
title="Click to enter CPNP No."
>
<span className="flex items-center gap-1.5">
<Save className="w-3 h-3 opacity-50" />
Click to fill...
</span>
</button>
);
})()}
</td>
</tr>
);
})}
{paginatedData.length === 0 && (
<tr>
<td colSpan={columns.length} className="px-4 py-8 text-center text-slate-500">
{cpnpFilter === 'all'
? 'No cosmetic items found.'
: cpnpFilter === 'present'
? 'No cosmetic items with CPNP No. found.'
: 'No cosmetic items missing CPNP No. found.'}
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500">
<div>
Showing {paginatedData.length} of {filteredData.length} filtered items
</div>
<div className="flex items-center gap-2">
<button
disabled={page === 1}
onClick={() => setPage(p => p - 1)}
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Previous
</button>
<span className="text-slate-300">Page {page} of {totalPages || 1}</span>
<button
disabled={page === totalPages || totalPages === 0}
onClick={() => setPage(p => p + 1)}
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
>
Next
</button>
</div>
</div>
</div>
</div>
);
}
+3 -1
View File
@@ -1,5 +1,6 @@
import React, { useMemo, useState } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../lib/utils';
@@ -9,6 +10,7 @@ interface DataCompletenessProps {
}
export function DataCompleteness({ data, headers }: DataCompletenessProps) {
const COLUMNS = useColumns();
const [sortCol, setSortCol] = useState<number | 'score'>('score');
const [sortDesc, setSortDesc] = useState(false);
const [page, setPage] = useState(1);
+85
View File
@@ -0,0 +1,85 @@
import React, { useState } from 'react';
import { Calendar } from 'lucide-react';
import { cn } from '../lib/utils';
interface DateFilterPopoverProps {
selectedRange: { start: string; end: string };
onRangeChange: (range: { start: string; end: string }) => void;
onClose: () => void;
}
export function DateFilterPopover({ selectedRange, onRangeChange, onClose }: DateFilterPopoverProps) {
const [startDate, setStartDate] = useState(selectedRange.start);
const [endDate, setEndDate] = useState(selectedRange.end);
const handleApply = () => {
onRangeChange({ start: startDate, end: endDate });
onClose();
};
const handleClear = () => {
setStartDate('');
setEndDate('');
onRangeChange({ start: '', end: '' });
onClose();
};
const hasValue = startDate || endDate;
return (
<div
className="absolute top-full left-0 mt-1 w-72 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-4 flex flex-col gap-4 animate-in fade-in zoom-in-95 duration-100"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2 text-xs font-medium text-slate-400 uppercase tracking-wider">
<Calendar className="w-4 h-4" />
Filter by Date Range
</div>
<div className="space-y-3">
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-slate-500 uppercase tracking-wider">From</label>
<input
type="date"
value={startDate}
onChange={e => setStartDate(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-[10px] text-slate-500 uppercase tracking-wider">To</label>
<input
type="date"
value={endDate}
onChange={e => setEndDate(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500"
/>
</div>
</div>
<div className="flex items-center justify-between pt-3 border-t border-slate-700">
<button
onClick={handleClear}
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
>
Clear
</button>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-3 py-1.5 text-[10px] font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded transition-colors"
>
Cancel
</button>
<button
onClick={handleApply}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg"
>
Apply
</button>
</div>
</div>
</div>
);
}
+262 -31
View File
@@ -1,9 +1,12 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X, Undo2 } from 'lucide-react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X, Undo2, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ConfirmModal } from './ConfirmModal';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { SyncStatusPill } from './SyncStatusPill';
interface DimensionsViewProps {
data: ExcelRow[];
@@ -35,6 +38,7 @@ interface NearDuplicateCluster {
}
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState, rowStatuses, onRevertRow }: DimensionsViewProps) {
const COLUMNS = useColumns();
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
@@ -51,10 +55,23 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
targetGroupKey: string;
selectedIndices: number[];
} | null>(null);
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState<string[]>([]);
const [classFilter, setClassFilter] = useState<string[]>([]);
const [search, setSearch] = usePersistentState('dimensions-search', '');
const [lineFilter, setLineFilter] = usePersistentState<string[]>('dimensions-lineFilter', []);
const [classFilter, setClassFilter] = usePersistentState<string[]>('dimensions-classFilter', []);
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null);
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState<number | null>(null);
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [lastClickedIndex, setLastClickedIndex] = useState<number | null>(null);
const [activeClusterKey, setActiveClusterKey] = useState<string | null>(null);
const [openDropdown, setOpenDropdown] = useState<{ rowIndex: number; field: 'outer' | 'units' | 'moq' } | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const isDraggingRef = React.useRef(false);
const dragStartRef = React.useRef<number | null>(null);
const hoveredIndexRef = React.useRef<number | null>(null);
const activeClusterKeyRef = React.useRef<string | null>(null);
const groups = useMemo(() => {
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
@@ -133,10 +150,15 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
if (search || lineFilter.length > 0 || classFilter.length > 0) {
const s = search.toLowerCase();
result = result.filter(g => {
const matchesSearch = !search || g.rows.some(({ row }) =>
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
const matchesSearch = !search || (() => {
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return true;
return g.rows.some(({ row }) => {
const articleNo = String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
const articleName = String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
return terms.every(term => articleNo.includes(term) || articleName.includes(term));
});
})();
const matchesLine = lineFilter.length === 0 || g.rows.some(({ row }) => lineFilter.includes(String(row[COLUMNS.LINE] || '')));
const matchesClass = classFilter.length === 0 || g.rows.some(({ row }) => classFilter.includes(String(row[COLUMNS.CLASSIFICATION] || '')));
@@ -205,6 +227,55 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
return clusters;
}, [groups]);
React.useEffect(() => {
const handleGlobalMouseUp = () => {
if (isDraggingRef.current && activeClusterKeyRef.current) {
const clusterKey = activeClusterKeyRef.current;
const start = dragStartRef.current;
const end = hoveredIndexRef.current;
if (start !== null && end !== null) {
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
if (cluster) {
const allClusterRows = cluster.groups.flatMap(g => g.rows);
const s = Math.min(start, end);
const e = Math.max(start, end);
const indicesToSelect = allClusterRows.slice(s, e + 1).map(r => r.index);
setClusterSelections(prev => {
const next = new Set(prev[clusterKey] ?? []);
indicesToSelect.forEach(idx => next.add(idx));
return { ...prev, [clusterKey]: next };
});
}
}
}
isDraggingRef.current = false;
dragStartRef.current = null;
hoveredIndexRef.current = null;
activeClusterKeyRef.current = null;
setIsDragging(false);
setDragStart(null);
setHoveredIndex(null);
setActiveClusterKey(null);
};
window.addEventListener('mouseup', handleGlobalMouseUp);
return () => window.removeEventListener('mouseup', handleGlobalMouseUp);
}, [nearDuplicateClusters]);
React.useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (openDropdown && !(e.target as Element)?.closest('.dropdown-container')) {
setOpenDropdown(null);
}
};
if (openDropdown) {
document.addEventListener('click', handleClickOutside);
return () => document.removeEventListener('click', handleClickOutside);
}
}, [openDropdown]);
const toggleGroup = (key: string) => {
const next = new Set(expandedGroups);
if (next.has(key)) {
@@ -223,6 +294,47 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
setPendingAction({ group, sourceRow, fieldType: 'all' });
};
const handleSingleFieldChange = async (rowIndex: number, field: 'outer' | 'units' | 'moq', value: string | number) => {
const row = data[rowIndex];
const updatedRow = [...row];
if (field === 'outer') {
const parts = String(value).split('x').map(Number);
if (parts.length === 3) {
updatedRow[COLUMNS.OUTER_L] = parts[0];
updatedRow[COLUMNS.OUTER_W] = parts[1];
updatedRow[COLUMNS.OUTER_H] = parts[2];
}
} else if (field === 'units') {
updatedRow[COLUMNS.UNITS_OUTER] = value;
} else if (field === 'moq') {
updatedRow[COLUMNS.MOQ] = value;
}
await onSaveRow(rowIndex, updatedRow);
onCaptureState(`Updated ${field} for article ${row[COLUMNS.ARTICLE_NO]}`);
setOpenDropdown(null);
};
const getUniqueGroupValues = (group: DimensionGroup, field: 'outer' | 'units' | 'moq'): string[] => {
const values = new Set<string>();
group.rows.forEach(({ row }) => {
if (field === 'outer') {
const outer = `${row[COLUMNS.OUTER_L]}x${row[COLUMNS.OUTER_W]}x${row[COLUMNS.OUTER_H]}`;
if (outer !== 'undefinedxundefinedxundefined' && row[COLUMNS.OUTER_L] != null) {
values.add(outer);
}
} else if (field === 'units') {
const val = String(row[COLUMNS.UNITS_OUTER] ?? '');
if (val) values.add(val);
} else if (field === 'moq') {
const val = String(row[COLUMNS.MOQ] ?? '');
if (val) values.add(val);
}
});
return Array.from(values).sort();
};
const handleVerifyGroup = (e: React.MouseEvent, group: DimensionGroup) => {
e.stopPropagation();
group.rows.forEach(({ row, index }) => {
@@ -310,7 +422,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
};
return (
<div className="space-y-6">
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "space-y-6"}>
{isFullscreen && (
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
title="Exit fullscreen"
>
<X className="w-5 h-5" />
</button>
)}
<div className="flex flex-wrap items-center gap-4 bg-slate-800/50 p-4 rounded-lg border border-slate-700">
<div className="relative flex-1 min-w-[250px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
@@ -334,6 +455,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
<div className="flex items-center gap-2">
<div className="relative">
<button
id="dims-filter-trigger-line"
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
@@ -345,6 +467,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</button>
{openFilter === 'line' && (
<ColumnFilterPopover
triggerId="dims-filter-trigger-line"
uniqueValues={uniqueLines}
selectedValues={lineFilter}
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -359,6 +482,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
<div className="relative">
<button
id="dims-filter-trigger-class"
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
@@ -370,6 +494,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</button>
{openFilter === 'class' && (
<ColumnFilterPopover
triggerId="dims-filter-trigger-class"
uniqueValues={uniqueClasses}
selectedValues={classFilter}
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -405,6 +530,13 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
/>
Show only inconsistent
</label>
<button
onClick={() => setIsFullscreen(true)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors ml-4"
title="Fullscreen"
>
<Maximize2 className="w-5 h-5" />
</button>
<div className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded border border-amber-500/20 whitespace-nowrap">
{groups.filter(g => g.isInconsistent).length} ISSUES
</div>
@@ -492,24 +624,64 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</div>
<div className="divide-y divide-violet-500/10">
{allClusterRows.map(({ row, index }) => {
{allClusterRows.map(({ row, index }, rowIdx) => {
const isSelected = selection.has(index);
const isInDragRange = isDragging && activeClusterKey === clusterKey && dragStart !== null && hoveredIndex !== null &&
((rowIdx >= dragStart && rowIdx <= hoveredIndex) || (rowIdx <= dragStart && rowIdx >= hoveredIndex));
return (
<div
key={index}
className={cn(
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors",
isSelected && "bg-violet-500/10"
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors select-none",
isSelected && "bg-violet-500/10",
isInDragRange && "bg-violet-500/20 ring-1 ring-violet-500/30"
)}
onClick={() => setClusterSelections(prev => {
const current = new Set(prev[clusterKey] ?? []);
if (current.has(index)) current.delete(index); else current.add(index);
return { ...prev, [clusterKey]: current };
})}
onMouseDown={(e) => {
// Only handle left click
if (e.button !== 0) return;
e.preventDefault();
isDraggingRef.current = true;
dragStartRef.current = rowIdx;
hoveredIndexRef.current = rowIdx;
activeClusterKeyRef.current = clusterKey;
setIsDragging(true);
setDragStart(rowIdx);
setHoveredIndex(rowIdx);
setActiveClusterKey(clusterKey);
}}
onMouseEnter={() => {
if (isDraggingRef.current && activeClusterKeyRef.current === clusterKey) {
hoveredIndexRef.current = rowIdx;
setHoveredIndex(rowIdx);
}
}}
onClick={(e) => {
if (!isDragging) {
if (e.shiftKey && lastClickedIndex !== null) {
const start = Math.min(lastClickedIndex, rowIdx);
const end = Math.max(lastClickedIndex, rowIdx);
const indicesToSelect = allClusterRows.slice(start, end + 1).map(r => r.index);
setClusterSelections(prev => {
const next = new Set(prev[clusterKey] ?? []);
indicesToSelect.forEach(idx => next.add(idx));
return { ...prev, [clusterKey]: next };
});
} else {
setClusterSelections(prev => {
const current = new Set(prev[clusterKey] ?? []);
if (current.has(index)) current.delete(index); else current.add(index);
return { ...prev, [clusterKey]: current };
});
}
setLastClickedIndex(rowIdx);
}
}}
>
<input
type="checkbox"
checked={isSelected}
checked={isSelected || isInDragRange}
readOnly
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
/>
@@ -631,6 +803,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
<tbody className="divide-y divide-slate-700/50">
{group.rows.map(({ row, index }) => {
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
const syncStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
return (
<tr
key={index}
@@ -642,6 +815,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
<td className="px-4 py-3">
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
<div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div>
{syncStatus && <SyncStatusPill status={syncStatus} className="mt-1" />}
</td>
<td className="px-4 py-3 text-slate-400 font-mono">
{row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? row[COLUMNS.INNER_L] : '-'} × {row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? row[COLUMNS.INNER_W] : '-'} × {row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? row[COLUMNS.INNER_H] : '-'}
@@ -650,48 +824,105 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
"px-4 py-3 font-mono",
group.discrepancies.outer ? "text-amber-300" : "text-slate-400"
)}>
<div className="flex items-center gap-2 group/cell">
<div className="flex items-center gap-2 group/cell dropdown-container relative">
<button
onClick={() => handleSyncField(group, row, 'outer')}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown?.rowIndex === index && openDropdown?.field === 'outer' ? null : { rowIndex: index, field: 'outer' });
}}
disabled={syncing?.key === group.key}
title="Apply these Outer Dims to all in group"
title="Change value"
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
{syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
{syncing?.key === group.key && syncing?.field === 'outer' ? <Loader2 className="w-3 h-3 animate-spin" /> : <ChevronDown className="w-3 h-3" />}
</button>
<span>{row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? row[COLUMNS.OUTER_L] : '-'} × {row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? row[COLUMNS.OUTER_W] : '-'} × {row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? row[COLUMNS.OUTER_H] : '-'}</span>
{openDropdown?.rowIndex === index && openDropdown?.field === 'outer' && (
<div className="absolute top-full left-0 mt-1 bg-slate-800 border border-slate-600 rounded-md shadow-lg z-50 py-1 min-w-[150px]">
{getUniqueGroupValues(group, 'outer').map(val => (
<button
key={val}
onClick={(e) => {
e.stopPropagation();
handleSingleFieldChange(index, 'outer', val);
}}
className="w-full px-3 py-1.5 text-left text-xs text-slate-300 hover:bg-emerald-600/20 hover:text-emerald-400 transition-colors"
>
{val}
</button>
))}
</div>
)}
</div>
</td>
<td className={cn(
"px-4 py-3",
group.discrepancies.units ? "text-amber-300 font-bold" : "text-slate-400"
)}>
<div className="flex items-center gap-2 group/cell">
<div className="flex items-center gap-2 group/cell dropdown-container relative">
<button
onClick={() => handleSyncField(group, row, 'units')}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown?.rowIndex === index && openDropdown?.field === 'units' ? null : { rowIndex: index, field: 'units' });
}}
disabled={syncing?.key === group.key}
title="Apply this Units/Outer to all in group"
title="Change value"
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
{syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
{syncing?.key === group.key && syncing?.field === 'units' ? <Loader2 className="w-3 h-3 animate-spin" /> : <ChevronDown className="w-3 h-3" />}
</button>
<span>{row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? row[COLUMNS.UNITS_OUTER] : '-'}</span>
{openDropdown?.rowIndex === index && openDropdown?.field === 'units' && (
<div className="absolute top-full left-0 mt-1 bg-slate-800 border border-slate-600 rounded-md shadow-lg z-50 py-1 min-w-[100px]">
{getUniqueGroupValues(group, 'units').map(val => (
<button
key={val}
onClick={(e) => {
e.stopPropagation();
handleSingleFieldChange(index, 'units', val);
}}
className="w-full px-3 py-1.5 text-left text-xs text-slate-300 hover:bg-emerald-600/20 hover:text-emerald-400 transition-colors"
>
{val}
</button>
))}
</div>
)}
</div>
</td>
<td className={cn(
"px-4 py-3",
group.discrepancies.moq ? "text-amber-300 font-bold" : "text-slate-400"
)}>
<div className="flex items-center gap-2 group/cell">
<div className="flex items-center gap-2 group/cell dropdown-container relative">
<button
onClick={() => handleSyncField(group, row, 'moq')}
onClick={(e) => {
e.stopPropagation();
setOpenDropdown(openDropdown?.rowIndex === index && openDropdown?.field === 'moq' ? null : { rowIndex: index, field: 'moq' });
}}
disabled={syncing?.key === group.key}
title="Apply this MOQ to all in group"
title="Change value"
className="p-1 hover:bg-emerald-600/20 text-slate-600 hover:text-emerald-400 rounded opacity-0 group-hover:opacity-100 transition-opacity"
>
{syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <Layers className="w-3 h-3" />}
{syncing?.key === group.key && syncing?.field === 'moq' ? <Loader2 className="w-3 h-3 animate-spin" /> : <ChevronDown className="w-3 h-3" />}
</button>
<span>{row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? row[COLUMNS.MOQ] : '-'}</span>
{openDropdown?.rowIndex === index && openDropdown?.field === 'moq' && (
<div className="absolute top-full left-0 mt-1 bg-slate-800 border border-slate-600 rounded-md shadow-lg z-50 py-1 min-w-[100px]">
{getUniqueGroupValues(group, 'moq').map(val => (
<button
key={val}
onClick={(e) => {
e.stopPropagation();
handleSingleFieldChange(index, 'moq', val);
}}
className="w-full px-3 py-1.5 text-left text-xs text-slate-300 hover:bg-emerald-600/20 hover:text-emerald-400 transition-colors"
>
{val}
</button>
))}
</div>
)}
</div>
</td>
<td className="px-4 py-3 text-right">
+113 -25
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, useCallback } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
import { generateGemini } from '../services/gemini';
import { cn } from '../lib/utils';
@@ -39,6 +40,48 @@ const DimensionInput = ({ label, value, isModified, onChange, placeholder }: Dim
</div>
);
const AutoResizeTextarea = ({
id,
value,
onChange,
onKeyDown,
className,
placeholder
}: {
id?: string;
value: string;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown?: (e: React.KeyboardEvent) => void;
className?: string;
placeholder?: string;
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const adjustHeight = useCallback(() => {
const textarea = textareaRef.current;
if (textarea) {
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}
}, []);
useEffect(() => {
adjustHeight();
}, [value, adjustHeight]);
return (
<textarea
id={id}
ref={textareaRef}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
className={cn(className, "overflow-hidden resize-none")}
placeholder={placeholder}
/>
);
};
interface FieldEditorProps {
title: string;
field: string;
@@ -111,13 +154,13 @@ const FieldEditor = ({
</div>
)}
</div>
<textarea
<AutoResizeTextarea
id={`field-${field}`}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
className={cn(
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
"w-full min-h-[128px] bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
isModified ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
placeholder={`Enter ${title}...`}
@@ -126,6 +169,7 @@ const FieldEditor = ({
);
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
const COLUMNS = useColumns();
const [formData, setFormData] = useState({
longDe: row[COLUMNS.LONG_DE] || '',
longEn: row[COLUMNS.LONG_EN] || '',
@@ -141,6 +185,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
moq: row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? String(row[COLUMNS.MOQ]) : '',
productType: row[COLUMNS.CATEGORIZATION_CODE] !== undefined && row[COLUMNS.CATEGORIZATION_CODE] !== null ? String(row[COLUMNS.CATEGORIZATION_CODE]) : '',
itemToLogistic: row[COLUMNS.ITEM_TO_LOGISTIC] !== undefined && row[COLUMNS.ITEM_TO_LOGISTIC] !== null ? String(row[COLUMNS.ITEM_TO_LOGISTIC]) : '',
});
const [loadingField, setLoadingField] = useState<string | null>(null);
@@ -176,20 +222,27 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
moq: COLUMNS.MOQ,
detailsDe: COLUMNS.DETAILS_DE,
detailsEn: COLUMNS.DETAILS_EN,
productType: COLUMNS.CATEGORIZATION_CODE,
itemToLogistic: COLUMNS.ITEM_TO_LOGISTIC,
};
const colIndex = (colMap as Record<string, number>)[field as string];
if (colIndex === undefined) return false;
return formData[field] !== (row[colIndex] !== undefined && row[colIndex] !== null ? String(row[colIndex]) : '');
};
const handleGenerate = async (field: keyof typeof formData) => {
const handleGenerate = (field: keyof typeof formData) => {
console.log('[EditPanel] handleGenerate called for field:', field);
setError(null); // Clear any previous error
setPendingGeminiField(field);
};
const executeGenerate = async () => {
if (!pendingGeminiField) return;
if (!pendingGeminiField) {
console.warn('[EditPanel] executeGenerate called but no field pending');
return;
}
const field = pendingGeminiField;
setPendingGeminiField(null);
console.log('[EditPanel] Starting generation for field:', field);
setLoadingField(field);
setError(null);
@@ -197,7 +250,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
let prompt = '';
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. CRITICAL: Output ONLY the translated or generated content. Do not include any introductions, conclusions, or conversational text. Translate EVERYTHING, including phrases in ALL CAPS (maintain the all-caps casing for those phrases in the translation). Your response must contain only the final product description.";
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. CRITICAL: Output ONLY the translated or generated content. Do not include any introductions, conclusions, or conversational text. Translate EVERYTHING, including phrases in ALL CAPS (maintain the all-caps casing for those phrases in the translation). Your response must be complete, with all sentences finished and logically concluded. Never end mid-sentence.";
if (field === 'longDe') {
if (formData.longEn) {
@@ -208,11 +261,13 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
3. Keep all technical specs intact.
4. Translate every single paragraph into natural, commercial German for toy buyers.
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
6. ENSURE the translation is complete and does not cut off. The final sentence must be fully finished.
English Text to Translate:
${formData.longEn}`;
} else {
prompt = `Based on the following product details, generate a long commercial description in German. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long. Include features, benefits, and material if available.\n\n${baseContext}`;
prompt = `Based on the following product details, generate a long commercial description in German. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long. Include features, benefits, and material if available.
IMPORTANT: Ensure the description is complete and ends with a finished sentence.\n\n${baseContext}`;
}
} else if (field === 'longEn') {
if (formData.longDe) {
@@ -223,29 +278,47 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
3. Keep all technical specs intact.
4. Translate every single paragraph into natural, commercial English for toy buyers.
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
6. ENSURE the translation is complete and does not cut off. The final sentence must be fully finished.
German Text to Translate:
${formData.longDe}`;
} else {
prompt = `Based on the following product details, generate a long commercial description in English. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long.\n\n${baseContext}`;
prompt = `Based on the following product details, generate a long commercial description in English. It should be fluent, oriented towards B2B toy buyers, 3-5 paragraphs long.
IMPORTANT: Ensure the description is complete and ends with a finished sentence.\n\n${baseContext}`;
}
} else if (field === 'shortDe') {
if (formData.shortEn) {
prompt = `Translate exactly this short English product description into professional German for the toy market. IMPORTANT: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortEn}`;
prompt = `Translate exactly this short English product description into professional German for the toy market.
IMPORTANT:
1. Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting.
2. ENSURE the translation is complete and does not cut off mid-sentence.\n\n${formData.shortEn}`;
} else if (formData.longDe) {
const targetChars = Math.round(formData.longDe.length * 0.3);
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
prompt = `Create a concise summary of the following German product description.
CRITICAL: The summary MUST be between 250 and 450 characters long.
Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers.
Do not use bullet points — write flowing prose.
IMPORTANT: Ensure the summary is a complete thought and ends with a finished sentence.\n\nOriginal description:\n${formData.longDe}`;
} else {
prompt = `Based on the following product details, generate a short commercial description in German (2-4 sentences max).\n\n${baseContext}`;
prompt = `Based on the following product details, generate a short commercial description in German.
CRITICAL: Length MUST be between 250 and 450 characters.
Ensure the text is complete and ends with a finished sentence.\n\n${baseContext}`;
}
} else if (field === 'shortEn') {
if (formData.shortDe) {
prompt = `Translate exactly this short German product description into professional English for the toy market. IMPORTANT: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortDe}`;
prompt = `Translate exactly this short German product description into professional English for the toy market.
IMPORTANT:
1. Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting.
2. ENSURE the translation is complete and does not cut off mid-sentence.\n\n${formData.shortDe}`;
} else if (formData.longEn) {
const targetChars = Math.round(formData.longEn.length * 0.3);
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
prompt = `Create a concise summary of the following English product description.
CRITICAL: The summary MUST be between 250 and 450 characters long.
Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers.
Do not use bullet points — write flowing prose.
IMPORTANT: Ensure the summary is a complete thought and ends with a finished sentence.\n\nOriginal description:\n${formData.longEn}`;
} else {
prompt = `Based on the following product details, generate a short commercial description in English (2-4 sentences max).\n\n${baseContext}`;
prompt = `Based on the following product details, generate a short commercial description in English.
CRITICAL: Length MUST be between 250 and 450 characters.
Ensure the text is complete and ends with a finished sentence.\n\n${baseContext}`;
}
}
@@ -257,8 +330,10 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const textarea = document.getElementById(`field-${field}`);
if (textarea) textarea.focus();
}, 100);
setPendingGeminiField(null);
} catch (err: any) {
setError(err.message || 'An error occurred during generation.');
setPendingGeminiField(null); // Close modal on error so error message is visible in panel
} finally {
setLoadingField(null);
}
@@ -285,6 +360,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
newRow[COLUMNS.MOQ] = formData.moq;
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
newRow[COLUMNS.CATEGORIZATION_CODE] = formData.productType;
newRow[COLUMNS.ITEM_TO_LOGISTIC] = formData.itemToLogistic;
onSave(rowIndex, newRow);
};
@@ -324,11 +401,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
</div>
<div className="col-span-2">
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
<textarea
<AutoResizeTextarea
value={formData.detailsDe}
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
className={cn(
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
"w-full min-h-[80px] bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
isModified('detailsDe') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
)}
placeholder="Enter details in German..."
@@ -336,11 +413,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
</div>
<div className="col-span-2">
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
<textarea
<AutoResizeTextarea
value={formData.detailsEn}
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
className={cn(
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
"w-full min-h-[80px] bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
isModified('detailsEn') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
)}
placeholder="Enter details in English..."
@@ -371,6 +448,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
<DimensionInput label="Units per Outer" value={formData.unitsOuter} field="unitsOuter" isModified={isModified('unitsOuter')} onChange={(val) => setFormData(p => ({...p, unitsOuter: val}))} />
<DimensionInput label="MOQ" value={formData.moq} field="moq" isModified={isModified('moq')} onChange={(val) => setFormData(p => ({...p, moq: val}))} />
</div>
<div className="grid grid-cols-2 gap-3 pt-2 border-t border-slate-700/30">
<DimensionInput label="CategorizationCode" value={formData.productType} field="productType" isModified={isModified('productType')} onChange={(val) => setFormData(p => ({...p, productType: val}))} />
<DimensionInput label="Item to Logistic" value={formData.itemToLogistic} field="itemToLogistic" isModified={isModified('itemToLogistic')} onChange={(val) => setFormData(p => ({...p, itemToLogistic: val}))} />
</div>
</div>
<FieldEditor
@@ -408,7 +490,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
field="shortDe"
value={formData.shortDe}
isModified={isModified('shortDe')}
canTranslate={!!formData.shortEn || !!formData.longDe}
canTranslate={!!formData.shortEn}
isGenerated={generatedFields.has('shortDe')}
isLoading={loadingField === 'shortDe'}
onGenerate={() => handleGenerate('shortDe')}
@@ -420,7 +502,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
field="shortEn"
value={formData.shortEn}
isModified={isModified('shortEn')}
canTranslate={!!formData.shortDe || !!formData.longEn}
canTranslate={!!formData.shortDe}
isGenerated={generatedFields.has('shortEn')}
isLoading={loadingField === 'shortEn'}
onGenerate={() => handleGenerate('shortEn')}
@@ -456,10 +538,16 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
<ConfirmModal
isOpen={!!pendingGeminiField}
isLoading={loadingField !== null}
onConfirm={executeGenerate}
onCancel={() => setPendingGeminiField(null)}
title="Confirm AI Action"
message={`Are you sure you want to use Gemini to ${((pendingGeminiField === 'longEn' && formData.longDe) || (pendingGeminiField === 'shortEn' && formData.shortDe)) ? 'translate' : 'generate'} the ${pendingGeminiField} field?`}
message={`Are you sure you want to use Gemini to ${
((pendingGeminiField === 'shortEn' && formData.shortDe) ||
(pendingGeminiField === 'shortDe' && formData.shortEn) ||
(pendingGeminiField === 'longEn' && formData.longDe) ||
(pendingGeminiField === 'longDe' && formData.longEn))
? 'translate' : 'generate'} the ${pendingGeminiField} field?`}
type="info"
confirmText="Start Generation"
/>
+425 -44
View File
@@ -1,34 +1,98 @@
import React, { useState, useEffect } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2 } from 'lucide-react';
import React, { useState, useEffect, useMemo } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2, Maximize2, Check, Loader2, CloudUpload } from 'lucide-react';
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
import { ExcelRow, COLUMNS } from '../types';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { cn } from '../lib/utils';
import { usePersistentState } from '../contexts/FilterContext';
import { previewBusinessCentralSync, applyBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
import { SyncStatusPill } from './SyncStatusPill';
interface HistoryViewProps {
headers: string[];
data: ExcelRow[];
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: number) => void;
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: string) => void;
onEdit?: (rowIndex: number) => void;
sessionToken?: string;
}
export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: HistoryViewProps) {
type HistorySyncStatus = 'bc_pending' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed';
interface HistorySyncRecord {
selected: boolean;
status: HistorySyncStatus;
previewToken?: string;
error?: string;
warning?: string;
}
type HistorySyncMap = Record<string, HistorySyncRecord>;
function readStoredHistorySyncMap(): HistorySyncMap {
try {
const raw = localStorage.getItem('history-bcSync');
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as HistorySyncMap;
} catch {
return {};
}
}
export function HistoryView({ headers, data, onRevert, onEdit }: HistoryViewProps) {
const COLUMNS = useColumns();
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [search, setSearch] = usePersistentState('history-search', '');
const [userFilter, setUserFilter] = usePersistentState('history-userFilter', '');
const [statusFilter, setStatusFilter] = usePersistentState<'all' | 'bc_pending' | 'previewed' | 'preview_only' | 'synced' | 'failed'>('history-statusFilter', 'all');
const [bcHistorySync, setBcHistorySync] = useState<HistorySyncMap>(() => readStoredHistorySyncMap());
const [bcSyncBusy, setBcSyncBusy] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
loadHistory();
}, []);
useEffect(() => {
if (data.length > 0) {
loadHistory();
}
}, [data]);
useEffect(() => {
try {
localStorage.setItem('history-bcSync', JSON.stringify(bcHistorySync));
} catch {
// Ignore storage quota or serialization errors.
}
}, [bcHistorySync]);
const loadHistory = async () => {
setLoading(true);
const data = await getHistory(sessionToken);
setHistory(data);
const historyData = await getHistory();
setHistory(historyData);
setLoading(false);
};
useEffect(() => {
if (history.length === 0) return;
const validKeys = new Set(history.map(entry => String(entry.id || `${entry.product_id}-${entry.changed_at}`)));
setBcHistorySync(prev => {
let changed = false;
const next: Record<string, HistorySyncRecord> = {};
for (const [key, value] of Object.entries(prev)) {
if (!validKeys.has(key)) {
changed = true;
continue;
}
next[key] = value as HistorySyncRecord;
}
return changed ? next : prev;
});
}, [history]);
const handleRevert = (entry: HistoryEntry) => {
if (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
const currentRow = data.find(r => String(r[0]) === entry.product_id);
@@ -70,12 +134,186 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
}).format(date);
};
const filteredHistory = history.filter(entry =>
entry.article_name?.toLowerCase().includes(search.toLowerCase()) ||
entry.product_id.toLowerCase().includes(search.toLowerCase()) ||
entry.changed_by?.toLowerCase().includes(search.toLowerCase())
const uniqueUsers = Array.from(new Set(history.map(e => e.changed_by))).sort();
const getEntryKey = (entry: HistoryEntry) => String(entry.id || `${entry.product_id}-${entry.changed_at}`);
const getEntryStatus = (entry: HistoryEntry): HistorySyncStatus => {
return bcHistorySync[getEntryKey(entry)]?.status || 'bc_pending';
};
const getEntrySelected = (entry: HistoryEntry): boolean => {
return bcHistorySync[getEntryKey(entry)]?.selected || false;
};
// history is asc (oldest first) so index+1 = chronological #
// display newest first by reversing for render only
const filteredHistory = [...history].reverse().filter(entry => {
if (userFilter && entry.changed_by !== userFilter) return false;
const status = getEntryStatus(entry);
if (statusFilter !== 'all' && status !== statusFilter) return false;
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return true;
const searchableText = `${entry.article_name} ${entry.product_id} ${entry.changed_by}`.toLowerCase();
return terms.every(term => searchableText.includes(term));
});
const selectedEntries = useMemo(
() => history.filter(entry => getEntrySelected(entry)),
[history, bcHistorySync]
);
const statusCounts = useMemo(() => {
const counts = { bc_pending: 0, previewed: 0, preview_only: 0, synced: 0, failed: 0 };
history.forEach(entry => {
const status = getEntryStatus(entry);
if (status in counts) counts[status as keyof typeof counts] += 1;
});
return counts;
}, [history, bcHistorySync]);
const updateEntry = (entry: HistoryEntry, patch: Partial<HistorySyncRecord>) => {
const key = getEntryKey(entry);
setBcHistorySync(prev => ({
...prev,
[key]: {
selected: prev[key]?.selected ?? false,
status: prev[key]?.status ?? 'bc_pending',
previewToken: prev[key]?.previewToken,
error: prev[key]?.error,
...prev[key],
...patch,
},
}));
};
const selectAllVisible = (selected: boolean) => {
setBcHistorySync(prev => {
const next = { ...prev };
filteredHistory.forEach(entry => {
const key = getEntryKey(entry);
next[key] = {
selected,
status: next[key]?.status ?? 'bc_pending',
previewToken: next[key]?.previewToken,
error: next[key]?.error,
warning: next[key]?.warning,
};
});
return next;
});
};
const discardSelected = () => {
if (selectedEntries.length === 0) return;
if (!window.confirm(`Discard ${selectedEntries.length} pending sync(s)? They will be reset to "Pending BC" and will not be synced to Business Central.`)) return;
setBcHistorySync(prev => {
const next = { ...prev };
selectedEntries.forEach(entry => {
const key = getEntryKey(entry);
next[key] = { selected: false, status: 'bc_pending' };
});
return next;
});
};
const previewSelected = async () => {
if (selectedEntries.length === 0) return;
setBcSyncBusy(true);
try {
for (const entry of [...selectedEntries].sort((a, b) => {
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
if (timeDelta !== 0) return timeDelta;
return String(a.id || '').localeCompare(String(b.id || ''));
})) {
const preview = await previewBusinessCentralSync(headers, entry.new_data);
if (!preview.success) {
updateEntry(entry, { status: 'failed', error: preview.error || 'Preview failed', warning: undefined, selected: true });
continue;
}
updateEntry(entry, {
status: 'previewed',
previewToken: preview.previewToken,
error: undefined,
warning: undefined,
selected: true,
});
}
} finally {
setBcSyncBusy(false);
}
};
const syncSelected = async () => {
if (selectedEntries.length === 0) return;
setBcSyncBusy(true);
try {
const orderedEntries = [...selectedEntries].sort((a, b) => {
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
if (timeDelta !== 0) return timeDelta;
return String(a.id || '').localeCompare(String(b.id || ''));
});
for (const entry of orderedEntries) {
updateEntry(entry, { status: 'syncing', error: undefined, selected: true });
const key = getEntryKey(entry);
const currentRecord = bcHistorySync[key];
let previewToken = currentRecord?.previewToken;
if (!previewToken) {
const preview = await previewBusinessCentralSync(headers, entry.new_data);
if (!preview.success) {
updateEntry(entry, { status: 'failed', error: preview.error || 'Preview failed', warning: undefined, selected: true });
continue;
}
previewToken = preview.previewToken;
updateEntry(entry, { status: 'previewed', previewToken, error: undefined, warning: undefined, selected: true });
}
const apply = await applyBusinessCentralSync(headers, entry.new_data, previewToken);
if (!apply.success) {
if (isPreviewTokenMismatchError(apply.error)) {
const refreshedPreview = await previewBusinessCentralSync(headers, entry.new_data);
if (refreshedPreview.success) {
updateEntry(entry, {
status: 'previewed',
previewToken: refreshedPreview.previewToken,
error: undefined,
warning: undefined,
selected: true,
});
const retryApply = await applyBusinessCentralSync(headers, entry.new_data, refreshedPreview.previewToken);
if (retryApply.success) {
updateEntry(entry, {
status: 'synced',
error: undefined,
warning: retryApply.warning,
selected: false,
previewToken: retryApply.previewToken || refreshedPreview.previewToken,
});
continue;
}
updateEntry(entry, { status: 'failed', error: retryApply.error || 'BC sync failed', warning: retryApply.warning, selected: true });
continue;
}
}
updateEntry(entry, { status: 'failed', error: apply.error || 'BC sync failed', warning: apply.warning, selected: true });
continue;
}
updateEntry(entry, {
status: 'synced',
error: undefined,
warning: apply.warning,
selected: false,
previewToken: apply.previewToken || previewToken,
});
}
} finally {
setBcSyncBusy(false);
}
};
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-full text-slate-400">
@@ -86,16 +324,52 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
}
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500"}>
{isFullscreen && (
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-4 right-4 z-50 p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
>
<X className="w-5 h-5" />
</button>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<History className="w-8 h-8 text-blue-500" />
Change History
</h1>
<p className="text-slate-400 mt-1">Review and revert any changes made to products.</p>
<p className="text-slate-400 mt-1">
{history.length > 0 ? (
<><span className="text-white font-semibold">{history.length}</span> total records{filteredHistory.length !== history.length && <> &mdash; showing <span className="text-white font-semibold">{filteredHistory.length}</span></>}</>
) : 'Review and revert any changes made to products.'}
</p>
</div>
<div className="flex items-center gap-4">
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500 pointer-events-none" />
<select
value={userFilter}
onChange={e => setUserFilter(e.target.value)}
className={cn(
"pl-9 pr-8 py-2 bg-slate-800 border rounded-md text-sm focus:outline-none focus:border-blue-500 transition-colors w-52 appearance-none",
userFilter ? "border-blue-500 text-white" : "border-slate-700 text-slate-400"
)}
>
<option value="">All users</option>
{uniqueUsers.map(u => (
<option key={u} value={u}>{u}</option>
))}
</select>
{userFilter && (
<button
onClick={() => setUserFilter('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="relative">
<Tag className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<input
@@ -121,6 +395,89 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
<RotateCcw className="w-4 h-4" />
Refresh
</button>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors border border-slate-700"
title={isFullscreen ? "Exit fullscreen" : "Enter fullscreen"}
>
<Maximize2 className="w-5 h-5" />
</button>
</div>
</div>
<div className="bg-slate-900/70 border border-slate-800 rounded-xl p-4 flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 text-xs text-slate-400">
<span className="font-semibold text-slate-200">{selectedEntries.length}</span> selected
<span className="text-slate-600">·</span>
<span>{statusCounts.bc_pending} pending</span>
<span>{statusCounts.previewed} previewed</span>
<span>{statusCounts.preview_only} preview only</span>
<span>{statusCounts.synced} synced</span>
<span>{statusCounts.failed} failed</span>
</div>
<div className="ml-auto flex items-center gap-2 flex-wrap">
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value as any)}
className="bg-slate-800 border border-slate-700 rounded-md px-3 py-2 text-xs text-slate-200 focus:outline-none focus:border-blue-500"
>
<option value="all">All statuses</option>
<option value="bc_pending">Pending BC</option>
<option value="previewed">Previewed</option>
<option value="preview_only">Preview only</option>
<option value="synced">Synced BC</option>
<option value="failed">Failed</option>
</select>
<button
onClick={() => selectAllVisible(true)}
className="px-3 py-2 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs border border-slate-700"
>
Select all visible
</button>
<button
onClick={() => selectAllVisible(false)}
className="px-3 py-2 rounded-md bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs border border-slate-700"
>
Clear selection
</button>
<button
onClick={discardSelected}
disabled={selectedEntries.length === 0}
className={cn(
"px-3 py-2 rounded-md text-xs border transition-colors",
selectedEntries.length === 0
? "bg-slate-700 text-slate-500 border-slate-600 cursor-not-allowed"
: "bg-slate-800 hover:bg-red-900/40 text-red-400 border-red-500/30 hover:border-red-500/60"
)}
>
Discard selected
</button>
<button
onClick={previewSelected}
disabled={bcSyncBusy || selectedEntries.length === 0}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold border transition-colors",
bcSyncBusy || selectedEntries.length === 0
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
: "bg-indigo-600 hover:bg-indigo-500 text-white border-indigo-500/30"
)}
>
{bcSyncBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
Preview selected
</button>
<button
onClick={syncSelected}
disabled={bcSyncBusy || selectedEntries.length === 0}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-xs font-semibold border transition-colors",
bcSyncBusy || selectedEntries.length === 0
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
: "bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-500/30"
)}
>
{bcSyncBusy ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <CloudUpload className="w-3.5 h-3.5" />}
Sync selected to BC
</button>
</div>
</div>
@@ -135,9 +492,12 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
</div>
) : (
<div className="divide-y divide-slate-800">
{filteredHistory.map((entry) => {
{filteredHistory.map((entry, filteredIdx) => {
const isExpanded = expandedId === entry.id;
const changes = getChangedFields(entry.old_data, entry.new_data);
const globalNumber = history.indexOf(entry) + 1;
const status = getEntryStatus(entry);
const selected = getEntrySelected(entry);
return (
<div key={entry.id} className={cn(
@@ -149,8 +509,19 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
className="p-4 flex items-center gap-4 cursor-pointer"
onClick={() => setExpandedId(isExpanded ? null : entry.id)}
>
<input
type="checkbox"
checked={selected}
onClick={e => e.stopPropagation()}
onChange={() => updateEntry(entry, { selected: !selected })}
className="accent-blue-500"
/>
{isExpanded ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
<div className="w-8 text-right shrink-0">
<span className="text-xs font-mono text-slate-500">#{globalNumber}</span>
</div>
<div className="flex-1 grid grid-cols-4 gap-4 items-center">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center text-blue-500 font-bold shrink-0">
@@ -172,33 +543,41 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
<span className="text-sm">{formatDate(entry.changed_at)}</span>
</div>
<div className="flex items-center justify-end gap-3 text-sm">
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
</span>
<button
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
if (idx !== -1) onEdit(idx);
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleRevert(entry);
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
>
<RotateCcw className="w-4 h-4" />
Revert
</button>
<div className="flex flex-col items-end gap-2 text-sm">
<div className="flex items-center justify-end gap-3 flex-wrap">
<SyncStatusPill status={status} />
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
</span>
<button
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
if (idx !== -1) onEdit(idx);
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleRevert(entry);
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
>
<RotateCcw className="w-4 h-4" />
Revert
</button>
</div>
{status === 'failed' && bcHistorySync[getEntryKey(entry)]?.error && (
<div className="max-w-[40rem] rounded-md border border-red-500/20 bg-red-500/10 px-3 py-2 text-xs text-red-200">
<span className="font-semibold">BC error:</span> {bcHistorySync[getEntryKey(entry)].error}
</div>
)}
</div>
</div>
</div>
@@ -210,7 +589,8 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
<table className="w-full text-sm">
<thead>
<tr className="bg-slate-800/50 text-slate-400 text-left">
<th className="px-4 py-2 font-medium">Field</th>
<th className="px-4 py-2 font-medium w-8">#</th>
<th className="px-4 py-2 font-medium">Column</th>
<th className="px-4 py-2 font-medium">Original Value</th>
<th className="px-4 py-2 font-medium">New Value</th>
</tr>
@@ -218,6 +598,7 @@ export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: H
<tbody className="divide-y divide-slate-800">
{changes.map((change, idx) => (
<tr key={idx} className="hover:bg-slate-700/20">
<td className="px-4 py-2 text-slate-600 text-xs font-mono">{idx + 1}</td>
<td className="px-4 py-2 text-slate-300 font-medium whitespace-nowrap">
{change.header}
</td>
+361 -15
View File
@@ -1,8 +1,12 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow } from '../types';
import { Search, Filter, ChevronDown, ChevronUp, X } from 'lucide-react';
import React, { useState, useMemo, useEffect } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2, Check, Loader2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { buildBusinessCentralMappingPreview } from '../services/businessCentralMapping';
import { applyBusinessCentralSync, previewBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
interface MatrixViewProps {
data: ExcelRow[];
@@ -11,13 +15,21 @@ interface MatrixViewProps {
}
export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
const resolvedCols = useColumns();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [search, setSearch] = useState('');
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [search, setSearch] = usePersistentState('matrix-search', '');
const [bcSku, setBcSku] = usePersistentState('matrix-bcSku', '');
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('matrix-columnFilters', {});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const [sortCol, setSortCol] = usePersistentState<number | null>('matrix-sortCol', null);
const [sortDesc, setSortDesc] = usePersistentState('matrix-sortDesc', false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [bcValidationResult, setBcValidationResult] = useState<any>(null);
const [bcValidationLoading, setBcValidationLoading] = useState(false);
const [bcApplyLoading, setBcApplyLoading] = useState(false);
const [bcValidationError, setBcValidationError] = useState<string | null>(null);
const [bcValidationWarning, setBcValidationWarning] = useState<string | null>(null);
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
@@ -66,6 +78,25 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
return filteredData.slice(start, start + pageSize);
}, [filteredData, page, pageSize]);
const bcPreviewRow = useMemo(() => {
const targetSku = String(bcSku || '').trim();
if (!targetSku) return null;
return data.find(row => String(row[resolvedCols.ARTICLE_NO] ?? '') === targetSku) || null;
}, [bcSku, data, resolvedCols.ARTICLE_NO]);
const bcMappingPreview = useMemo(() => {
if (!bcPreviewRow) return null;
return buildBusinessCentralMappingPreview(headers, bcPreviewRow);
}, [bcPreviewRow, headers]);
useEffect(() => {
setBcValidationResult(null);
setBcValidationError(null);
setBcValidationWarning(null);
}, [bcSku]);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({});
const handleSort = (col: number) => {
if (sortCol === col) {
setSortDesc(!sortDesc);
@@ -75,6 +106,25 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
}
};
const handleResize = (colIndex: number, e: React.MouseEvent) => {
e.preventDefault();
const startX = e.pageX;
const startWidth = columnWidths[colIndex] || 150;
const onMouseMove = (moveEvent: MouseEvent) => {
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const getUniqueValues = (col: number) => {
const values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort();
@@ -96,11 +146,67 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
setPage(1);
};
const handlePreviewBcSync = async () => {
if (!bcPreviewRow) return;
setBcValidationLoading(true);
setBcValidationError(null);
setBcValidationWarning(null);
try {
const result = await previewBusinessCentralSync(headers, bcPreviewRow);
if (!result.success) {
setBcValidationResult(null);
setBcValidationError(result.error || 'Preview failed');
return;
}
setBcValidationResult(result);
} finally {
setBcValidationLoading(false);
}
};
const handleApplyBcSync = async () => {
if (!bcPreviewRow || !bcValidationResult?.previewToken) return;
setBcApplyLoading(true);
setBcValidationError(null);
setBcValidationWarning(null);
try {
const result = await applyBusinessCentralSync(headers, bcPreviewRow, bcValidationResult.previewToken);
if (!result.success) {
if (isPreviewTokenMismatchError(result.error)) {
const refreshedPreview = await previewBusinessCentralSync(headers, bcPreviewRow);
if (refreshedPreview.success) {
setBcValidationResult(refreshedPreview);
const retry = await applyBusinessCentralSync(headers, bcPreviewRow, refreshedPreview.previewToken);
if (retry.success) {
setBcValidationResult(prev => retry.preview ? { ...retry.preview, hasChanges: false } : prev);
if (retry.warning) {
setBcValidationWarning(retry.warning);
}
return;
}
setBcValidationError(retry.error || 'Apply failed');
return;
}
}
setBcValidationError(result.error || 'Apply failed');
if (result.warning) {
setBcValidationWarning(result.warning);
}
return;
}
setBcValidationResult(prev => result.preview ? { ...result.preview, hasChanges: false } : prev);
if (result.warning) {
setBcValidationWarning(result.warning);
}
} finally {
setBcApplyLoading(false);
}
};
const formatCellValue = (val: any, header: string = '') => {
if (val === undefined || val === null || val === '') return '';
// Convert header to lowercase for checks
const h = header.toLowerCase();
const h = (header || '').toLowerCase();
// Handle date columns - Excel serial dates are numbers >= 25569 (Jan 1, 1970)
if (h.includes('date') || h.includes('launch') || h.includes('ready')) {
@@ -156,13 +262,40 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
const totalPages = Math.ceil(filteredData.length / pageSize);
return (
<div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden"}>
{isFullscreen && (
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
title="Exit fullscreen"
>
<X className="w-5 h-5" />
</button>
)}
<div className="p-4 border-b border-slate-700 bg-slate-800/50 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-4 flex-wrap justify-end">
<div className="relative min-w-[260px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Preview BC mapping by SKU..."
value={bcSku}
onChange={e => setBcSku(e.target.value)}
className="w-full pl-9 pr-10 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium"
/>
{bcSku && (
<button
onClick={() => setBcSku('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<div className="relative min-w-[300px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
@@ -193,21 +326,225 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
Clear All Column Filters
</button>
)}
<button
onClick={() => setIsFullscreen(true)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
title="Fullscreen"
>
<Maximize2 className="w-5 h-5" />
</button>
</div>
</div>
<div className="overflow-auto flex-1">
<table className="w-full text-left text-sm whitespace-nowrap">
{bcSku && (
<div className="mx-4 mt-4 mb-2 rounded-xl border border-slate-700 bg-slate-900/70 p-4">
{!bcMappingPreview ? (
<div className="text-sm text-slate-400">
No row found for SKU <span className="text-white font-semibold">{bcSku}</span>.
</div>
) : (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<button
onClick={handlePreviewBcSync}
disabled={bcValidationLoading}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors border",
bcValidationLoading
? "bg-slate-700 text-slate-400 border-slate-600 cursor-wait"
: "bg-blue-600 hover:bg-blue-500 text-white border-blue-500/30"
)}
>
{bcValidationLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
Preview BC sync
</button>
<button
onClick={handleApplyBcSync}
disabled={bcApplyLoading || !bcValidationResult?.previewToken}
className={cn(
"inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors border",
bcApplyLoading || !bcValidationResult?.previewToken
? "bg-slate-700 text-slate-400 border-slate-600 cursor-not-allowed"
: "bg-emerald-600 hover:bg-emerald-500 text-white border-emerald-500/30"
)}
>
{bcApplyLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />}
Apply to BC
</button>
<span className="text-xs text-slate-500">
Preview checks the current BC row before any write happens.
</span>
</div>
{bcValidationError && (
<div className="rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3 text-sm text-red-300">
{bcValidationError}
</div>
)}
{bcValidationWarning && !bcValidationError && (
<div className="rounded-lg border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-300">
{bcValidationWarning}
</div>
)}
{bcValidationResult && (
<div className="rounded-lg border border-slate-700 bg-slate-950/40 p-4">
<div className="flex items-center justify-between gap-3 mb-3">
<div>
<h3 className="text-sm font-semibold text-white">Business Central validation</h3>
<p className="text-xs text-slate-400">
{bcValidationResult.hasChanges
? 'Changes detected and ready for apply when the BC endpoint is writable.'
: 'No differences detected against BC.'}
</p>
</div>
<div className={cn(
"text-xs px-2 py-1 rounded-full border",
bcValidationResult.hasChanges
? "border-amber-500/20 bg-amber-500/10 text-amber-300"
: "border-emerald-500/20 bg-emerald-500/10 text-emerald-300"
)}>
{bcValidationResult.hasChanges ? 'Pending changes' : 'In sync'}
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
{(['items', 'itemUnitsOfMeasure', 'itemUnits40HC'] as const).map(sectionKey => {
const section = bcValidationResult[sectionKey];
const changed = section.changes.filter((change: any) => change.changed);
return (
<div key={sectionKey} className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h4 className="text-sm font-semibold text-white">
API `{sectionKey}`
</h4>
<p className="text-xs text-slate-400">
{changed.length} changed field{changed.length === 1 ? '' : 's'}
</p>
</div>
{section.supported === false && (
<span className="text-[10px] px-2 py-1 rounded-full border border-amber-500/20 bg-amber-500/10 text-amber-300">
preview only
</span>
)}
{!section.writeConfigured && (
<span className="text-[10px] px-2 py-1 rounded-full border border-slate-600 text-slate-400">
write not configured
</span>
)}
</div>
{changed.length === 0 ? (
<div className="text-xs text-slate-500">No changes for this endpoint.</div>
) : (
<div className="space-y-2">
{section.supported === false && section.supportReason && (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-3 py-2 text-[11px] text-amber-200">
{section.supportReason}
</div>
)}
{changed.map((change: any) => (
<div key={change.targetField} className="grid grid-cols-3 gap-3 text-xs">
<div className="text-slate-400 truncate">{change.sourceLabel}</div>
<div className="text-slate-500 truncate">{change.targetField}</div>
<div className="text-white truncate">
{String(change.before ?? '—')} <span className="text-slate-500"></span> {String(change.after ?? '—')}
</div>
</div>
))}
</div>
)}
</div>
);
})}
</div>
</div>
)}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-white">API `items` preview</h3>
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
</div>
</div>
<div className="space-y-2">
{bcMappingPreview.itemsFields.map(field => (
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
<div className="text-slate-400">{field.sourceLabel}</div>
<div className="text-slate-500 truncate">{field.targetField}</div>
<div className="text-white truncate">{String(field.value ?? '—')}</div>
</div>
))}
</div>
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
{JSON.stringify(bcMappingPreview.itemsPayload, null, 2)}
</pre>
</div>
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-white">API `itemUnitsOfMeasure2` preview</h3>
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
</div>
</div>
<div className="space-y-2">
{bcMappingPreview.itemUnitsFields.map(field => (
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
<div className="text-slate-400">{field.sourceLabel}</div>
<div className="text-slate-500 truncate">{field.targetField}</div>
<div className="text-white truncate">{String(field.value ?? '—')}</div>
</div>
))}
</div>
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
{JSON.stringify(bcMappingPreview.itemUnitsOfMeasurePayload, null, 2)}
</pre>
</div>
<div className="rounded-lg border border-slate-700 bg-slate-950/60 p-4">
<div className="flex items-center justify-between mb-3">
<div>
<h3 className="text-sm font-semibold text-white">API `itemUnitsOfMeasure2` 40HC preview</h3>
<p className="text-xs text-slate-400">SKU {bcMappingPreview.articleNo}</p>
</div>
</div>
<div className="space-y-2">
{bcMappingPreview.itemUnits40HCFields.map(field => (
<div key={field.targetField} className="grid grid-cols-3 gap-3 text-xs">
<div className="text-slate-400">{field.sourceLabel}</div>
<div className="text-slate-500 truncate">{field.targetField}</div>
<div className="text-white truncate">{String(field.value ?? '—')}</div>
</div>
))}
</div>
<pre className="mt-4 text-[11px] leading-relaxed bg-slate-950 border border-slate-800 rounded p-3 overflow-auto text-slate-200">
{JSON.stringify(bcMappingPreview.itemUnits40HCPayload, null, 2)}
</pre>
</div>
</div>
</div>
)}
</div>
)}
<table className="w-full text-left text-sm whitespace-nowrap" style={{ tableLayout: 'fixed' }}>
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
<tr>
{headers.map((header, index) => (
<th
key={index}
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[index] || 150, minWidth: columnWidths[index] || 150 }}
>
<div className="flex items-center overflow-hidden">
<span
className="flex items-center gap-1 cursor-pointer hover:text-white"
className="flex items-center gap-1 cursor-pointer hover:text-white truncate"
onClick={() => handleSort(index)}
>
{header}
@@ -216,6 +553,7 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
)}
</span>
<button
id={`matrix-filter-trigger-${index}`}
onClick={(e) => {
e.stopPropagation();
setOpenFilterCol(openFilterCol === index ? null : index);
@@ -229,8 +567,15 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
</button>
</div>
{/* Resizer handle */}
<div
onMouseDown={(e) => handleResize(index, e)}
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
/>
{openFilterCol === index && (
<ColumnFilterPopover
triggerId={`matrix-filter-trigger-${index}`}
uniqueValues={getUniqueValues(index)}
selectedValues={columnFilters[index] || []}
onToggle={(val) => toggleColumnFilter(index, val)}
@@ -259,7 +604,8 @@ export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
return (
<td
key={colIndex}
className="px-4 py-3 text-slate-300 max-w-[200px] truncate"
className="px-4 py-3 text-slate-300 truncate border-r border-slate-700/30"
style={{ width: columnWidths[colIndex] || 150, minWidth: columnWidths[colIndex] || 150 }}
title={String(row[colIndex] || '')}
>
{formattedValue}
+317 -85
View File
@@ -1,7 +1,11 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Search, ChevronDown, ChevronUp, X, Edit2, Save, Filter, XCircle } from 'lucide-react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { Search, ChevronDown, ChevronUp, X, Edit2, Save, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { DateFilterPopover } from './DateFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
interface MissingDataViewProps {
data: ExcelRow[];
@@ -10,17 +14,34 @@ interface MissingDataViewProps {
onCaptureState: (message: string) => void;
}
function formatDateForInput(val: string): string {
if (!val) return '';
// If it's already YYYY-MM-DD, return it
if (/^\d{4}-\d{2}-\d{2}$/.test(val)) return val;
// If it's DD/MM/YYYY, convert to YYYY-MM-DD
const parts = val.split('/');
if (parts.length === 3) {
return `${parts[2]}-${parts[1].padStart(2, '0')}-${parts[0].padStart(2, '0')}`;
}
return val;
}
function formatDateFromInput(val: string): string {
// Always output YYYY-MM-DD as requested
return val || '';
}
function formatDateValue(val: any): string {
if (val === null || val === undefined || val === '') return '';
if (typeof val === 'number') {
if (val >= 25569 && val <= 60000) {
const excelEpoch = new Date(1899, 11, 30);
const date = new Date(excelEpoch.getTime() + val * 86400000);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
return date.toISOString().split('T')[0]; // Returns YYYY-MM-DD
}
return '';
}
return String(val);
return formatDateForInput(String(val));
}
function isEmptyOrEpoch(val: any): boolean {
@@ -36,7 +57,7 @@ function isEmptyOrEpoch(val: any): boolean {
return false;
}
type TabType = 'missingClass' | 'missingLaunch';
type TabType = 'missingLaunch' | 'launchInconsistent' | 'upcomingLaunch';
interface EditingState {
rowIndex: number;
@@ -46,59 +67,188 @@ interface EditingState {
}
export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: MissingDataViewProps) {
const [activeTab, setActiveTab] = useState<TabType>('missingClass');
const [search, setSearch] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const COLUMNS = useColumns();
const [activeTab, setActiveTab] = usePersistentState<TabType>('missingData-tab', 'missingLaunch');
const [search, setSearch] = usePersistentState('missingData-search', '');
const [sortCol, setSortCol] = usePersistentState<number | null>('missingData-sortCol', null);
const [sortDesc, setSortDesc] = usePersistentState('missingData-sortDesc', false);
const [page, setPage] = useState(1);
const [editing, setEditing] = useState<EditingState | null>(null);
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
const [showFilterMenu, setShowFilterMenu] = useState<number | null>(null);
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('missingData-columnFilters', {});
const [dateFilters, setDateFilters] = usePersistentState<Record<number, { start: string; end: string }>>('missingData-dateFilters', {});
const [openFilter, setOpenFilter] = useState<number | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const pageSize = 100;
const launchDateCol = useMemo(() =>
headers.findIndex(h => h.toLowerCase().includes('launch')), [headers]);
const readyToOrderCol = useMemo(() =>
headers.findIndex(h => h.toLowerCase().includes('ready')), [headers]);
const launchDateCol = useMemo(() => {
const idx = headers.findIndex(h => (h || '').toLowerCase().includes('launch'));
if (idx >= 0) return idx;
return headers.findIndex(h => (h || '').toLowerCase().includes('date'));
}, [headers]);
const readyToOrderCol = useMemo(() => {
const idx = headers.findIndex(h => (h || '').toLowerCase().includes('ready'));
if (idx >= 0) return idx;
return headers.findIndex(h => (h || '').toLowerCase().includes('order'));
}, [headers]);
const launchHeader = launchDateCol >= 0 ? headers[launchDateCol] : 'Launch Date';
const readyHeader = readyToOrderCol >= 0 ? headers[readyToOrderCol] : 'Ready to Order';
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
const columns = [
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 100 },
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 220 },
{ col: COLUMNS.CLASSIFICATION, label: 'Classification', width: 130 },
...(launchDateCol >= 0 ? [{ col: launchDateCol, label: launchHeader, width: 130 }] : []),
...(readyToOrderCol >= 0 ? [{ col: readyToOrderCol, label: readyHeader, width: 130 }] : []),
...((activeTab === 'launchInconsistent' || activeTab === 'upcomingLaunch') ? [{ col: -1, label: 'Days Until Launch', width: 120 }] : []),
];
if (activeTab === 'missingClass') {
result = result.filter(r => {
const val = r.row[COLUMNS.CLASSIFICATION];
return !val || String(val).trim() === '';
const columnUniqueValues = useMemo(() => {
const cols = columns.map(c => c.col);
const result: Record<number, Set<string>> = {};
cols.forEach(col => result[col] = new Set());
data.forEach(row => {
cols.forEach(col => {
let val: any = row[col];
if (col === launchDateCol || col === readyToOrderCol) {
val = formatDateValue(val) || String(val ?? '');
} else {
val = String(val ?? '');
}
result[col].add(val);
});
}
});
return result;
}, [data, columns, launchDateCol, readyToOrderCol]);
const getUniqueValues = (col: number): string[] => {
const values = columnUniqueValues[col];
if (!values) return [];
return Array.from(values).sort() as string[];
};
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index, status: 'ok' as string, daysUntil: 0 }));
const today = new Date();
today.setHours(0, 0, 0, 0);
if (activeTab === 'missingLaunch') {
result = result.filter(r => {
const val = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
return isEmptyOrEpoch(val);
const launchVal = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
const classVal = r.row[COLUMNS.CLASSIFICATION];
return isEmptyOrEpoch(launchVal) || isEmptyOrEpoch(classVal);
});
}
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
if (activeTab === 'launchInconsistent' || activeTab === 'upcomingLaunch') {
const minDate = new Date('2025-12-01');
result = result.map(r => {
const launchVal = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
const readyVal = readyToOrderCol >= 0 ? r.row[readyToOrderCol] : undefined;
const launchDate = formatDateValue(launchVal);
const readyDate = formatDateValue(readyVal);
let daysUntil = 0;
let status = 'ok';
if (launchDate && /^\d{4}-\d{2}-\d{2}$/.test(launchDate)) {
const launchD = new Date(launchDate);
const diffTime = launchD.getTime() - today.getTime();
daysUntil = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (launchD < minDate) {
return null;
}
if (activeTab === 'launchInconsistent') {
if (readyDate && /^\d{4}-\d{2}-\d{2}$/.test(readyDate)) {
const readyD = new Date(readyDate);
if (readyD > launchD) {
status = 'error_ready_after_launch';
} else {
status = 'ok';
}
} else {
status = 'ok';
}
} else if (activeTab === 'upcomingLaunch') {
if (daysUntil <= 0) {
status = 'past';
} else if (daysUntil <= 120) {
status = 'critical';
} else if (daysUntil <= 180) {
status = 'warning';
} else {
status = 'ok';
}
}
} else {
status = 'missing_launch';
}
return { ...r, status, daysUntil };
});
result = result.filter(r => r !== null);
if (activeTab === 'launchInconsistent') {
result = result.filter(r =>
r && (r.status === 'error_ready_after_launch' || (r.status === 'ok' && r.daysUntil > 0 && isEmptyOrEpoch(r.row[readyToOrderCol])))
);
}
if (activeTab === 'upcomingLaunch') {
result = result.filter(r => r && (r.status === 'warning' || r.status === 'critical' || r.status === 'past'));
}
}
(Object.entries(columnFilters) as [string, string][]).forEach(([colIdx, filterValue]) => {
if (!filterValue) return;
// Global search (SKU + Name) - Multi-word AND support
if (search) {
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length > 0) {
result = result.filter(r => {
const articleNo = String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
const articleName = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
return terms.every(term => articleNo.includes(term) || articleName.includes(term));
});
}
}
(Object.entries(columnFilters) as [string, string[]][]).forEach(([colIdx, filterValues]) => {
if (!filterValues || filterValues.length === 0) return;
const colIdxNum = parseInt(colIdx);
const filterLower = filterValue.toLowerCase();
if (colIdxNum === launchDateCol || colIdxNum === readyToOrderCol) return;
result = result.filter(r => {
const val: any = r.row[colIdxNum];
const displayVal = colIdxNum === launchDateCol || colIdxNum === readyToOrderCol
? formatDateValue(val) || String(val ?? '')
: String(val ?? '');
return displayVal.toLowerCase().includes(filterLower);
return filterValues.includes(displayVal);
});
});
(Object.entries(dateFilters) as [string, { start: string; end: string }][]).forEach(([colIdx, range]) => {
if (!range.start && !range.end) return;
const colIdxNum = parseInt(colIdx);
result = result.filter(r => {
const val = r.row[colIdxNum];
const dateStr = formatDateValue(val);
if (!dateStr || !/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) return false;
const rowDate = new Date(dateStr);
if (isNaN(rowDate.getTime())) return false;
if (range.start) {
const startDate = new Date(range.start);
if (rowDate < startDate) return false;
}
if (range.end) {
const endDate = new Date(range.end);
if (rowDate > endDate) return false;
}
return true;
});
});
@@ -134,8 +284,8 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
setEditing({
rowIndex,
classification: String(row[COLUMNS.CLASSIFICATION] || ''),
launchDate: launchDateCol >= 0 ? formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '') : '',
readyToOrder: readyToOrderCol >= 0 ? formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol] ?? '') : '',
launchDate: launchDateCol >= 0 ? formatDateForInput(formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '')) : '',
readyToOrder: readyToOrderCol >= 0 ? formatDateForInput(formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol] ?? '')) : '',
});
};
@@ -145,29 +295,31 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`);
const newRow = [...row];
newRow[COLUMNS.CLASSIFICATION] = editing.classification;
if (launchDateCol >= 0) newRow[launchDateCol] = editing.launchDate;
if (readyToOrderCol >= 0) newRow[readyToOrderCol] = editing.readyToOrder;
if (launchDateCol >= 0) newRow[launchDateCol] = formatDateFromInput(editing.launchDate);
if (readyToOrderCol >= 0) newRow[readyToOrderCol] = formatDateFromInput(editing.readyToOrder);
onSaveRow(editing.rowIndex, newRow);
setEditing(null);
};
const tabs: { id: TabType; label: string }[] = [
{ id: 'missingClass', label: 'Missing Classification' },
{ id: 'missingLaunch', label: 'Missing Launch Date' },
];
const columns = [
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 100 },
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 220 },
{ col: COLUMNS.CLASSIFICATION, label: 'Classification', width: 130 },
...(launchDateCol >= 0 ? [{ col: launchDateCol, label: launchHeader, width: 130 }] : []),
...(readyToOrderCol >= 0 ? [{ col: readyToOrderCol, label: readyHeader, width: 130 }] : []),
{ id: 'missingLaunch', label: 'Missing Data' },
{ id: 'launchInconsistent', label: 'Launch Date Inconsistencies' },
{ id: 'upcomingLaunch', label: 'Upcoming Launch Date' },
];
const editingRow = editing ? data[editing.rowIndex] : null;
return (
<div className="flex flex-col h-full">
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex flex-col h-full"}>
{isFullscreen && (
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
title="Exit fullscreen"
>
<X className="w-5 h-5" />
</button>
)}
<div className="flex flex-wrap gap-2 mb-6">
{tabs.map(tab => (
<button
@@ -183,6 +335,13 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
{tab.label}
</button>
))}
<button
onClick={() => setIsFullscreen(true)}
className="ml-auto p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
title="Fullscreen"
>
<Maximize2 className="w-5 h-5" />
</button>
</div>
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
@@ -214,7 +373,11 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
<table className="w-full text-left text-xs">
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
<tr>
{columns.map(({ col, label, width }) => (
{columns.map(({ col, label, width }) => {
const selectedFilters = columnFilters[col] || [];
const allValues = getUniqueValues(col);
const filterCount = selectedFilters.length;
return (
<th
key={col}
style={{ width, minWidth: width }}
@@ -230,38 +393,79 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
)}
</div>
<div className="mt-1 relative">
<input
type="text"
placeholder="Filter..."
value={columnFilters[col] || ''}
onChange={(e) => {
setColumnFilters(prev => ({ ...prev, [col]: e.target.value }));
setPage(1);
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-1.5 py-0.5 bg-slate-800 border border-slate-600 rounded text-[10px] text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
{columnFilters[col] && (
<button
onClick={(e) => {
e.stopPropagation();
setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; });
setPage(1);
}}
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
>
<XCircle className="w-3 h-3" />
</button>
{col === launchDateCol || col === readyToOrderCol ? (
<>
<button
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === col ? null : col); }}
className={cn(
"w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors",
dateFilters[col]?.start || dateFilters[col]?.end
? "border-indigo-500 text-white"
: "border-slate-600 text-slate-400 hover:border-slate-500"
)}
>
<span className="truncate">
{dateFilters[col]?.start || dateFilters[col]?.end
? `${dateFilters[col].start ? dateFilters[col].start : '...'} - ${dateFilters[col].end ? dateFilters[col].end : '...'}`
: 'Filter...'}
</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
</button>
{openFilter === col && (
<DateFilterPopover
selectedRange={dateFilters[col] || { start: '', end: '' }}
onRangeChange={(range) => setDateFilters(prev => ({ ...prev, [col]: range }))}
onClose={() => setOpenFilter(null)}
/>
)}
</>
) : (
<>
<button
id={`missing-filter-trigger-${col}`}
onClick={(e) => { e.stopPropagation(); setOpenFilter(openFilter === col ? null : col); }}
className={cn(
"w-full flex items-center justify-between px-1.5 py-0.5 bg-slate-800 border rounded text-[10px] transition-colors",
filterCount > 0 ? "border-indigo-500 text-white" : "border-slate-600 text-slate-400 hover:border-slate-500"
)}
>
<span className="truncate">{filterCount > 0 ? `${filterCount} selected` : 'Filter...'}</span>
<ChevronDown className={cn("w-3 h-3 transition-transform", openFilter === col && "rotate-180")} />
</button>
{openFilter === col && (
<ColumnFilterPopover
triggerId={`missing-filter-trigger-${col}`}
uniqueValues={allValues}
selectedValues={selectedFilters}
onToggle={(val) => setColumnFilters(prev => {
const current = prev[col] || [];
if (current.includes(val)) {
return { ...prev, [col]: current.filter(v => v !== val) };
}
return { ...prev, [col]: [...current, val] };
})}
onSelectAll={(vals) => setColumnFilters(prev => ({ ...prev, [col]: vals }))}
onClear={() => { setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; }); }}
onClose={() => setOpenFilter(null)}
/>
)}
</>
)}
</div>
</th>
))}
);})}
<th className="px-2 py-2 font-medium text-right" style={{ width: 60 }}></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{paginatedData.map(({ row, index }) => (
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
{paginatedData.map(({ row, index, status, daysUntil }) => (
<tr key={index} className={cn(
"hover:bg-slate-700/20 transition-colors",
false,
false,
false,
false
)}>
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: 100 }}>
{row[COLUMNS.ARTICLE_NO]}
</td>
@@ -300,6 +504,29 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
)}
</td>
)}
{(activeTab === 'launchInconsistent' || activeTab === 'upcomingLaunch') && (
<td className="px-3 py-2 font-mono" style={{ width: 120 }}>
{status === 'error_ready_after_launch' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-400 border border-red-500/40">
Ready &gt; Launch
</span>
) : status === 'warning' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-yellow-500/20 text-yellow-400 border border-yellow-500/40">
{daysUntil} days
</span>
) : status === 'critical' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-red-500/20 text-red-400 border border-red-500/40">
{daysUntil} days
</span>
) : status === 'past' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-red-600/30 text-red-300 border border-red-500/50">
Past
</span>
) : (
<span className="text-slate-500"></span>
)}
</td>
)}
<td className="px-3 py-2 text-right" style={{ width: 60 }}>
<button
onClick={() => openEdit(index, row)}
@@ -366,30 +593,34 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
<div className="flex-1 overflow-auto p-6 space-y-5">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">Classification</label>
<input
type="text"
<select
value={editing.classification}
onChange={e => setEditing(prev => prev ? { ...prev, classification: e.target.value } : prev)}
placeholder="e.g. CORE, OOC..."
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
editing.classification !== String(editingRow[COLUMNS.CLASSIFICATION] || '')
? "border-blue-500 focus:ring-blue-500"
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
/>
>
<option value="">Select classification...</option>
{getUniqueValues(COLUMNS.CLASSIFICATION).map(val => (
<option key={val} value={val}>{val}</option>
))}
</select>
</div>
{launchDateCol >= 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{launchHeader}</label>
<input
type="text"
type="date"
value={editing.launchDate}
onChange={e => setEditing(prev => prev ? { ...prev, launchDate: e.target.value } : prev)}
placeholder="DD/MM/YYYY"
onFocus={(e) => e.target.showPicker()}
onClick={(e) => e.currentTarget.showPicker()}
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:ring-1 transition-colors",
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer [color-scheme:dark]",
editing.launchDate !== (formatDateValue(editingRow[launchDateCol]) || String(editingRow[launchDateCol] ?? ''))
? "border-blue-500 focus:ring-blue-500"
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
@@ -402,12 +633,13 @@ export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: Mi
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{readyHeader}</label>
<input
type="text"
type="date"
value={editing.readyToOrder}
onChange={e => setEditing(prev => prev ? { ...prev, readyToOrder: e.target.value } : prev)}
placeholder="DD/MM/YYYY"
onFocus={(e) => e.target.showPicker()}
onClick={(e) => e.currentTarget.showPicker()}
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:ring-1 transition-colors",
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer [color-scheme:dark]",
editing.readyToOrder !== (formatDateValue(editingRow[readyToOrderCol]) || String(editingRow[readyToOrderCol] ?? ''))
? "border-blue-500 focus:ring-blue-500"
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
+35 -10
View File
@@ -1,7 +1,10 @@
import React, { useState } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X } from 'lucide-react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { usePersistentState } from '../contexts/FilterContext';
import { SyncStatusPill } from './SyncStatusPill';
interface PendingValidationViewProps {
data: ExcelRow[];
@@ -12,16 +15,21 @@ interface PendingValidationViewProps {
}
export function PendingValidationView({ data, pendingRows, rowStatuses, onRevertRow, onEdit }: PendingValidationViewProps) {
const [search, setSearch] = useState('');
const COLUMNS = useColumns();
const [search, setSearch] = usePersistentState('pending-search', '');
const [isFullscreen, setIsFullscreen] = useState(false);
const pendingEntries = Object.entries(pendingRows);
const filteredEntries = search
? pendingEntries.filter(([articleNo, { articleName }]) =>
articleNo.toLowerCase().includes(search.toLowerCase()) ||
articleName.toLowerCase().includes(search.toLowerCase())
)
: pendingEntries;
const filteredEntries = (() => {
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return pendingEntries;
return pendingEntries.filter(([articleNo, { articleName }]) => {
const searchableText = `${articleNo} ${articleName}`.toLowerCase();
return terms.every(term => searchableText.includes(term));
});
})();
const getFieldDiff = (original: ExcelRow, updated: ExcelRow, colIndex: number) => {
const orig = original[colIndex];
@@ -64,7 +72,16 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden">
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex-1 flex flex-col h-full overflow-hidden"}>
{isFullscreen && (
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
title="Exit fullscreen"
>
<X className="w-5 h-5" />
</button>
)}
<div className="flex items-center justify-between p-4 border-b border-slate-700">
<div className="flex items-center gap-3">
<Clock className="w-5 h-5 text-yellow-500" />
@@ -92,6 +109,13 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
</button>
)}
</div>
<button
onClick={() => setIsFullscreen(true)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
title="Fullscreen"
>
<Maximize2 className="w-5 h-5" />
</button>
</div>
</div>
@@ -121,6 +145,7 @@ export function PendingValidationView({ data, pendingRows, rowStatuses, onRevert
{articleNo}
</div>
<div className="text-sm text-slate-300">{articleName}</div>
{status && <SyncStatusPill status={status} />}
</div>
<div className="flex items-center gap-2">
<button
File diff suppressed because it is too large Load Diff
+97 -59
View File
@@ -1,8 +1,14 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
import React, { useEffect, useState, useMemo } from 'react';
import { ExcelRow } from '../types';
import { useColumns } from '../contexts/ColumnsContext';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { SyncStatusPill } from './SyncStatusPill';
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
interface ProductDescriptionsProps {
data: ExcelRow[];
@@ -10,23 +16,25 @@ interface ProductDescriptionsProps {
asinColumnIndex: number | null;
onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
dashboardDrilldown?: DashboardDrilldownRequest | null;
onDashboardDrilldownApplied?: () => void;
}
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ProductDescriptionsProps) {
const COLUMNS = useColumns();
const [isFullscreen, setIsFullscreen] = useState(false);
// Description columns that should only have Present/Missing filters
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState('');
const [licenseFilter, setLicenseFilter] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const [pageSize, setPageSize] = useState(100);
// Description columns that should only have Present/Missing filters
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN, COLUMNS.DETAILS_DE, COLUMNS.DETAILS_EN];
const [activeTab, setActiveTab] = usePersistentState<string>('descriptions-tab', 'all');
const [search, setSearch] = usePersistentState('descriptions-search', '');
const [lineFilter, setLineFilter] = usePersistentState('descriptions-lineFilter', '');
const [licenseFilter, setLicenseFilter] = usePersistentState('descriptions-licenseFilter', '');
const [sortCol, setSortCol] = usePersistentState<number | null>('descriptions-sortCol', null);
const [sortDesc, setSortDesc] = usePersistentState('descriptions-sortDesc', false);
const [pageSize, setPageSize] = usePersistentState('descriptions-pageSize', 100);
const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('descriptions-columnFilters', {});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
[COLUMNS.ARTICLE_NO]: 110,
@@ -41,6 +49,19 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
[COLUMNS.SHORT_EN]: 110,
});
useEffect(() => {
if (!dashboardDrilldown || dashboardDrilldown.tabId !== 'descriptions') return;
const focus = dashboardDrilldown.focus as TabType;
setActiveTab(focus);
setSearch('');
setLineFilter('');
setLicenseFilter('');
setColumnFilters({});
setPage(1);
onDashboardDrilldownApplied?.();
}, [dashboardDrilldown?.id, dashboardDrilldown?.tabId, dashboardDrilldown?.focus, onDashboardDrilldownApplied, setActiveTab, setSearch, setLineFilter, setLicenseFilter, setColumnFilters]);
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
@@ -64,11 +85,14 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
// Search filter
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length > 0) {
result = result.filter(r => {
const articleNo = String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase();
const articleName = String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase();
return terms.every(term => articleNo.includes(term) || articleName.includes(term));
});
}
}
// Dropdown filters
@@ -76,27 +100,29 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
// Column-specific filters (Excel-like)
console.log('[Filter] applying columnFilters:', columnFilters, 'result count before:', result.length);
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
const col = Number(colIdx);
const vals = selectedValues as string[];
if (vals.length > 0) {
// For description columns, filter by present/missing
if (DESCRIPTION_COLUMNS.includes(col)) {
result = result.filter(r => {
const hasValue = Boolean(r.row[col]);
const shouldInclude = (vals.includes('Present') && hasValue) || (vals.includes('Missing') && !hasValue);
return shouldInclude;
});
} else {
// For other columns, use regular value matching
const before = result.length;
result = result.filter(r => {
const cellVal = String(r.row[col] ?? '').trim();
return vals.some(v => v.trim() === cellVal);
});
console.log('[Filter] col', col, 'vals', vals, 'before:', before, 'after:', result.length);
}
result = result.filter(r => {
const cellVal = r.row[col];
if (DESCRIPTION_COLUMNS.includes(col)) {
// For description columns, we match synthetic 'Present'/'Missing' values
const hasValue = cellVal !== undefined && cellVal !== null && String(cellVal).trim() !== '';
const matchPresent = vals.includes('Present') && hasValue;
const matchMissing = vals.includes('Missing') && !hasValue;
return matchPresent || matchMissing;
} else {
// For other columns, use regular value matching with improved empty value handling
const cellStr = String(cellVal ?? '').trim();
// If the cell is empty/null/undefined, it matches if 'Empty' or '' is selected
return vals.some(v => {
const filterVal = String(v ?? '').trim();
return filterVal === cellStr;
});
}
});
}
});
@@ -177,34 +203,19 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
};
const getRowColor = (row: ExcelRow) => {
// EOL Rule: OOC Classification and 0 or negative stock (Item Available)
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
if (classification === 'OOC' && stock <= 0) {
return 'bg-yellow-500/10 hover:bg-yellow-500/20'; // EOL Highlight
}
const fields = [
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN],
row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]
];
const filled = fields.filter(Boolean).length;
if (filled === 4) return 'bg-green-900/10 hover:bg-green-900/20';
if (filled === 0) return 'bg-red-900/10 hover:bg-red-900/20';
return 'bg-yellow-900/10 hover:bg-yellow-900/20';
return '';
};
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
if (content !== undefined && content !== null && content !== '') return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30"></span>;
// EOL Exception: OOC and stock <= 0
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
if (classification === 'OOC' && stock <= 0) {
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">EOL not neccessary</span>;
}
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/30"> Missing</span>;
};
@@ -220,7 +231,16 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
];
return (
<div className="flex flex-col h-full">
<div className={isFullscreen ? "fixed inset-0 z-40 bg-[#041021] p-6 overflow-auto" : "flex flex-col h-full"}>
{isFullscreen && (
<button
onClick={() => setIsFullscreen(false)}
className="fixed top-4 right-4 z-50 w-10 h-10 flex items-center justify-center bg-slate-800/90 backdrop-blur border border-slate-600 text-white rounded hover:bg-slate-700 hover:scale-105 transition-all"
title="Exit fullscreen"
>
<X className="w-5 h-5" />
</button>
)}
<div className="flex flex-wrap gap-2 mb-6">
{tabs.map(tab => (
<button
@@ -236,9 +256,16 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
{tab.label}
</button>
))}
<button
onClick={() => setIsFullscreen(true)}
className="ml-auto p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded transition-colors"
title="Fullscreen"
>
<Maximize2 className="w-5 h-5" />
</button>
</div>
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800 p-4 rounded-xl border border-slate-700 shadow-sm">
<div className="flex flex-wrap items-center gap-4 mb-6 bg-slate-800 p-4 rounded-xl border border-slate-700 shadow-sm">
<div className="flex-1 min-w-[200px] relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
@@ -285,6 +312,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
<option value="">All Licenses</option>
{licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
</select>
<div className="ml-auto flex items-center gap-2 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-4 py-2 text-sm text-emerald-200">
<span className="text-[11px] uppercase tracking-[0.18em] text-emerald-300/80">In view</span>
<span className="font-semibold tabular-nums">{filteredData.length}</span>
</div>
</div>
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
@@ -317,6 +348,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
)}
</span>
<button
id={`desc-filter-trigger-${col}`}
onClick={(e) => {
e.stopPropagation();
setOpenFilterCol(openFilterCol === col ? null : col);
@@ -338,6 +370,7 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
{openFilterCol === col && (
<ColumnFilterPopover
triggerId={`desc-filter-trigger-${col}`}
uniqueValues={getUniqueValues(col)}
selectedValues={columnFilters[col] || []}
onToggle={(val) => toggleColumnFilter(col, val)}
@@ -367,11 +400,12 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
className={cn(
"transition-colors",
getRowColor(row),
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
""
)}
>
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>
{row[COLUMNS.ARTICLE_NO]}
</td>
<td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
{asinColumnIndex !== null && (
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[asinColumnIndex] || 100 }} title={row[asinColumnIndex]}>{row[asinColumnIndex] || '—'}</td>
@@ -428,6 +462,10 @@ export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, ro
<option value={100}>100 per page</option>
</select>
</div>
<div className="hidden md:flex items-center gap-2 rounded-full border border-slate-700 bg-slate-800 px-3 py-1 text-xs text-slate-300">
<span className="text-slate-500 uppercase tracking-[0.18em]">Filtered</span>
<span className="font-semibold tabular-nums text-white">{filteredData.length}</span>
</div>
<div className="flex items-center gap-2">
<button
disabled={page === 1}
+105
View File
@@ -0,0 +1,105 @@
import React, { useState, useEffect } from 'react';
import { X, Settings, Key, Save, Trash2, CheckCircle2 } from 'lucide-react';
import { cn } from '../lib/utils';
interface SettingsModalProps {
isOpen: boolean;
onClose: () => void;
}
export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
const [apiKey, setApiKey] = useState('');
const [saved, setSaved] = useState(false);
useEffect(() => {
if (isOpen) {
const stored = localStorage.getItem('GEMINI_API_KEY') || '';
setApiKey(stored);
setSaved(false);
}
}, [isOpen]);
const handleSave = () => {
if (apiKey.trim()) {
localStorage.setItem('GEMINI_API_KEY', apiKey.trim());
} else {
localStorage.removeItem('GEMINI_API_KEY');
}
setSaved(true);
setTimeout(() => {
setSaved(false);
onClose();
}, 1500);
};
const handleClear = () => {
localStorage.removeItem('GEMINI_API_KEY');
setApiKey('');
setSaved(true);
setTimeout(() => setSaved(false), 1500);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-300">
<div className="bg-slate-900 border border-slate-700 w-full max-w-md rounded-2xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300">
<div className="flex items-center justify-between p-4 border-b border-slate-700/50 bg-slate-800/20">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
<Settings className="w-5 h-5" />
</div>
<h3 className="text-lg font-semibold text-white uppercase tracking-tight text-xs">AI Settings</h3>
</div>
<button
onClick={onClose}
className="p-1.5 text-slate-500 hover:text-white hover:bg-slate-700 rounded-md transition-all"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 space-y-6">
<div className="space-y-2">
<label className="text-xs font-bold text-slate-400 uppercase tracking-widest flex items-center gap-2">
<Key className="w-3 h-3" />
Gemini API Key
</label>
<div className="relative">
<input
type="text"
value="AIzaSyDizFnYYnsBRBCfTuT9xhIPBXY0jFQd7HA"
readOnly
className="w-full bg-slate-800/50 border border-slate-700 rounded-lg px-4 py-3 text-sm text-slate-500 focus:outline-none transition-all cursor-not-allowed font-mono"
/>
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-blue-500">
<CheckCircle2 className="w-5 h-5" />
</div>
</div>
<p className="text-[10px] text-blue-400 leading-relaxed italic font-bold">
The Gemini API Key is now fixed for all users. Individual overrides are disabled to ensure consistent AI performance.
</p>
</div>
<div className="bg-blue-500/5 border border-blue-500/10 rounded-lg p-4">
<h4 className="text-[10px] font-bold text-blue-400 uppercase mb-2">Instructions</h4>
<ul className="text-[10px] text-slate-400 space-y-1.5 list-disc pl-3">
<li>Go to <a href="https://aistudio.google.com/" target="_blank" rel="noreferrer" className="text-blue-500 hover:underline">Google AI Studio</a></li>
<li>Enable billing in Settings if you want to use the paid tier</li>
<li>Generate a new API key and paste it here</li>
</ul>
</div>
</div>
<div className="flex items-center justify-end p-4 bg-slate-800/40 border-t border-slate-700/50">
<button
onClick={onClose}
className="px-6 py-2 bg-slate-700 hover:bg-slate-600 text-white text-xs font-black uppercase tracking-widest rounded-lg shadow-lg transition-all active:scale-95"
>
Close
</button>
</div>
</div>
</div>
);
}
+15 -9
View File
@@ -1,28 +1,34 @@
import React from 'react';
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle } from 'lucide-react';
import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles, LayoutDashboard, Users } from 'lucide-react';
import { cn } from '../lib/utils';
import { ControlTabId } from '../lib/controlDashboard';
interface SidebarProps {
activeModule: string;
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data') => void;
activeModule: ControlTabId;
setActiveModule: (m: ControlTabId) => void;
userEmail: string;
}
export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
const isMasterUser = userEmail?.toLowerCase() === 'christian.vidal@craze-group.com';
const MASTER_USERS = new Set([
'christian.vidal@craze-group.com',
'jingying.shi@craze-group.com',
]);
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data';
const navItems: { id: ModuleId; label: string; icon: React.ElementType }[] = [
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = [
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
{ id: 'matrix', label: 'Matrix', icon: Table },
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
{ id: 'article_details', label: 'Article Details', icon: Package },
{ id: 'dimensions', label: 'Dimensions', icon: Box },
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
{ id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
...(isMasterUser ? [
{ id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
{ id: 'pending_validation' as ControlTabId, label: 'Pending Validation', icon: Clock },
{ id: 'history' as ControlTabId, label: 'Change History', icon: History },
{ id: 'user_management' as ControlTabId, label: 'User Validation', icon: Users }
] : [])
];
+51
View File
@@ -0,0 +1,51 @@
import React from 'react';
import { cn } from '../lib/utils';
type SyncStatus = 'pending' | 'bc_pending' | 'synced' | 'failed' | 'saved' | 'edited' | 'error' | string | undefined;
interface SyncStatusPillProps {
status: SyncStatus;
className?: string;
}
export function SyncStatusPill({ status, className }: SyncStatusPillProps) {
const normalized = String(status || '').toLowerCase();
const meta = (() => {
if (normalized === 'bc_pending') {
return { label: 'Pending BC', tone: 'bg-blue-500/10 text-blue-300 border-blue-500/20' };
}
if (normalized === 'previewed') {
return { label: 'Previewed', tone: 'bg-indigo-500/10 text-indigo-300 border-indigo-500/20' };
}
if (normalized === 'preview_only' || normalized === 'preview only') {
return { label: 'Preview only', tone: 'bg-amber-500/10 text-amber-300 border-amber-500/20' };
}
if (normalized === 'syncing') {
return { label: 'Syncing BC', tone: 'bg-cyan-500/10 text-cyan-300 border-cyan-500/20' };
}
if (normalized === 'pending' || normalized === 'edited') {
return { label: 'Pending app', tone: 'bg-amber-500/10 text-amber-300 border-amber-500/20' };
}
if (normalized === 'synced' || normalized === 'saved') {
return { label: 'Synced BC', tone: 'bg-emerald-500/10 text-emerald-300 border-emerald-500/20' };
}
if (normalized === 'failed' || normalized === 'error') {
return { label: 'BC failed', tone: 'bg-red-500/10 text-red-300 border-red-500/20' };
}
if (!status) {
return { label: 'In app', tone: 'bg-slate-500/10 text-slate-300 border-slate-500/20' };
}
return { label: String(status), tone: 'bg-slate-500/10 text-slate-300 border-slate-500/20' };
})();
return (
<span className={cn(
'inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border whitespace-nowrap',
meta.tone,
className
)}>
{meta.label}
</span>
);
}
+167 -21
View File
@@ -1,12 +1,11 @@
import React, { useState, useRef, useEffect } from 'react';
import { Download, LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw } from 'lucide-react';
import { LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw, Maximize2, X } from 'lucide-react';
import { cn } from '../lib/utils';
interface TopBarProps {
stats: any;
onExport: () => void;
hasData: boolean;
hasUnsavedChanges: boolean;
activeModule?: string;
onRefresh?: () => void;
userEmail?: string;
onSignOut?: () => void;
canUndo: boolean;
@@ -18,16 +17,40 @@ interface TopBarProps {
onSaveAll: () => Promise<void>;
onRevertRow: (articleNo: string) => void;
isSavingAll: boolean;
bcQueueCount: number;
bcQueueEntries: Record<string, { articleName: string; selected: boolean; status: string; error?: string; warning?: string; categorizationCode?: string }>;
onToggleBcQueueSelection: (articleNo: string) => void;
onSelectAllBcQueue: (selected: boolean) => void;
onPreviewSelectedBcSync: () => Promise<void>;
onSyncSelectedBcSync: () => Promise<void>;
onDiscardSelectedBcQueue: () => void;
isSyncingBC: boolean;
isMaximized: boolean;
onToggleMaximize: () => void;
}
export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll }: TopBarProps) {
export function TopBar({ stats, activeModule, onRefresh, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll, bcQueueCount, bcQueueEntries, onToggleBcQueueSelection, onSelectAllBcQueue, onPreviewSelectedBcSync, onSyncSelectedBcSync, onDiscardSelectedBcQueue, isSyncingBC, isMaximized, onToggleMaximize }: TopBarProps) {
const [showPending, setShowPending] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const [showBcQueue, setShowBcQueue] = useState(false);
const pendingDropdownRef = useRef<HTMLDivElement>(null);
const bcQueueDropdownRef = useRef<HTMLDivElement>(null);
const selectedBcQueueCount = Object.values(bcQueueEntries).filter(entry => entry.selected).length;
const formatQueueStatus = (status: string) => {
const normalized = String(status || '').toLowerCase();
if (normalized === 'preview_only') return 'Preview only';
if (normalized === 'previewed') return 'Previewed';
if (normalized === 'syncing') return 'Syncing';
if (normalized === 'synced') return 'Synced';
if (normalized === 'failed') return 'Failed';
if (normalized === 'queued') return 'Queued';
return String(status || '');
};
useEffect(() => {
if (!showPending) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
if (pendingDropdownRef.current && !pendingDropdownRef.current.contains(e.target as Node)) {
setShowPending(false);
}
};
@@ -35,13 +58,28 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
return () => document.removeEventListener('mousedown', handler);
}, [showPending]);
useEffect(() => {
if (!showBcQueue) return;
const handler = (e: MouseEvent) => {
if (bcQueueDropdownRef.current && !bcQueueDropdownRef.current.contains(e.target as Node)) {
setShowBcQueue(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showBcQueue]);
// Close dropdown when all changes are saved/reverted
useEffect(() => {
if (pendingCount === 0) setShowPending(false);
}, [pendingCount]);
useEffect(() => {
if (bcQueueCount === 0) setShowBcQueue(false);
}, [bcQueueCount]);
return (
<header className="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
<header className="relative z-[200] bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 shadow-2xl">
<div className="flex items-center -ml-4">
<img
src="/logo.png"
@@ -50,7 +88,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
/>
</div>
{stats && (
{stats && activeModule === 'descriptions' && (
<div className="flex items-center gap-4 text-xs font-medium">
<div className="flex flex-col items-center">
<span className="text-slate-400">Total</span>
@@ -80,7 +118,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
)}
<div className="flex items-center gap-3">
<div className="relative" ref={dropdownRef}>
<div className="relative" ref={pendingDropdownRef}>
{/* Split button: Save All + dropdown toggle */}
<div className={cn(
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
@@ -120,7 +158,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
{/* Dropdown: list of pending changes */}
{showPending && pendingCount > 0 && (
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 overflow-hidden">
<div className="absolute right-0 top-full mt-2 w-80 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">Pending changes</span>
<span className="text-xs text-slate-500">{pendingCount} unsaved</span>
@@ -151,21 +189,128 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
)}
</div>
{hasData && (
<div className="relative" ref={bcQueueDropdownRef}>
<div className={cn(
"flex items-center rounded-md overflow-hidden shadow-lg transition-all",
bcQueueCount > 0 ? "shadow-blue-900/30" : "shadow-none opacity-40"
)}>
<button
onClick={onSyncSelectedBcSync}
disabled={isSyncingBC || bcQueueCount === 0}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-bold transition-all text-white",
bcQueueCount > 0
? "bg-blue-600 hover:bg-blue-500 disabled:opacity-60"
: "bg-slate-700 cursor-not-allowed"
)}
>
{isSyncingBC ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
{isSyncingBC
? 'Syncing...'
: bcQueueCount > 0
? `Sync all ${bcQueueCount} to BC`
: 'No BC queue'}
</button>
<button
onClick={() => bcQueueCount > 0 && setShowBcQueue(v => !v)}
disabled={isSyncingBC || bcQueueCount === 0}
className={cn(
"flex items-center px-2 py-2 text-white border-l transition-all",
bcQueueCount > 0
? "bg-blue-700 hover:bg-blue-600 border-blue-500/40"
: "bg-slate-700 cursor-not-allowed border-slate-600"
)}
title="View BC sync queue"
>
<ChevronDown className={cn("w-4 h-4 transition-transform", showBcQueue && "rotate-180")} />
</button>
</div>
{showBcQueue && bcQueueCount > 0 && (
<div className="absolute right-0 top-full mt-2 w-96 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-[9999] overflow-hidden">
<div className="px-3 py-2 border-b border-slate-700 flex items-center justify-between gap-2">
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider">BC sync queue</span>
<div className="flex items-center gap-2">
<button
onClick={() => onSelectAllBcQueue(true)}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-200"
>
All
</button>
<button
onClick={() => onSelectAllBcQueue(false)}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-slate-600 text-slate-200"
>
None
</button>
<button
onClick={onDiscardSelectedBcQueue}
disabled={isSyncingBC || selectedBcQueueCount === 0}
className="text-xs px-2 py-1 rounded bg-slate-700 hover:bg-red-900/50 text-red-400 border border-red-500/30 disabled:opacity-40 disabled:cursor-not-allowed"
>
Discard {selectedBcQueueCount > 0 ? `(${selectedBcQueueCount})` : ''}
</button>
<button
onClick={onPreviewSelectedBcSync}
disabled={isSyncingBC}
className="text-xs px-2 py-1 rounded bg-indigo-600 hover:bg-indigo-500 text-white disabled:opacity-60"
>
Preview {selectedBcQueueCount > 0 ? `(${selectedBcQueueCount})` : ''}
</button>
</div>
</div>
<div className="max-h-64 overflow-y-auto">
{Object.entries(bcQueueEntries).map(([articleNo, entry]) => (
<div
key={articleNo}
className="flex items-center gap-2 px-3 py-2.5 hover:bg-slate-700/50 border-b border-slate-700/50 last:border-0"
>
<input
type="checkbox"
checked={entry.selected}
onChange={() => onToggleBcQueueSelection(articleNo)}
className="accent-blue-500"
/>
<div className="flex-1 min-w-0">
<p className="text-xs font-mono text-slate-400">{articleNo}</p>
<p className="text-sm text-white truncate">{entry.articleName}</p>
<p className="text-[10px] text-cyan-300 truncate">
CategorizationCode: {entry.categorizationCode || 'empty - skipped'}
</p>
<p className="text-[10px] text-slate-500 uppercase">
{formatQueueStatus(entry.status)}
{entry.error ? ` · ${entry.error}` : ''}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
{onRefresh && (
<button
onClick={onExport}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${
hasUnsavedChanges
? 'bg-blue-600 hover:bg-blue-700 text-white'
: 'bg-slate-700 hover:bg-slate-600 text-slate-200'
}`}
onClick={onRefresh}
className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium bg-violet-600 hover:bg-violet-700 text-white transition-colors"
title="Hard Refresh - Reload all data from source"
>
<Download className="w-4 h-4" />
Export Updated Excel
{hasUnsavedChanges && <span className="w-2 h-2 rounded-full bg-red-500 ml-1 animate-pulse" />}
<RotateCcw className="w-4 h-4" />
Refresh
</button>
)}
<button
onClick={onToggleMaximize}
className="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium bg-slate-700 hover:bg-slate-600 text-slate-200 transition-colors"
title="Maximize - View table in full screen"
>
<Maximize2 className="w-4 h-4" />
MAXIMIZE
</button>
<button
onClick={onUndo}
disabled={!canUndo}
@@ -199,6 +344,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
</div>
)}
</div>
</header>
);
}
+353
View File
@@ -0,0 +1,353 @@
import React, { useState, useEffect } from 'react';
import { Users, Search, CheckCircle, XCircle, Trash2, Shield, Loader2, AlertCircle, Clock } from 'lucide-react';
import { AuthSession } from '../lib/auth';
import { safeFetch } from '../lib/supabase';
interface User {
id: string;
email: string;
created_at: string;
status: 'Master' | 'Validated' | 'Pending';
}
interface UserManagementViewProps {
session: AuthSession;
}
export function UserManagementView({ session }: UserManagementViewProps) {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [warning, setWarning] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [processingId, setProcessingId] = useState<string | null>(null);
const [deleteConfirmUser, setDeleteConfirmUser] = useState<User | null>(null);
const fetchUsers = async () => {
setLoading(true);
setError(null);
setWarning(null);
try {
const res = await safeFetch('/api/users-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action: 'list' })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to fetch users.');
}
const data = await res.json();
setUsers(data.users || []);
setWarning(data.warning || null);
} catch (err: any) {
console.error('[UserManagement] fetch error:', err);
setError(err.message || 'Error fetching user list.');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchUsers();
}, [session]);
const handleToggleValidation = async (user: User, approve: boolean) => {
setProcessingId(user.id);
setError(null);
setWarning(null);
try {
const res = await safeFetch('/api/users-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'validate',
targetUserId: user.id,
email: user.email,
validated: approve
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update user approval status.');
}
const data = await res.json().catch(() => ({}));
// Update local state
setUsers(prev => prev.map(u => {
if (u.id === user.id) {
return { ...u, status: approve ? 'Validated' : 'Pending' };
}
return u;
}));
if (data.warning) {
setWarning(data.warning);
}
} catch (err: any) {
setError(err.message || 'Error updating approval status.');
} finally {
setProcessingId(null);
}
};
const handleDeleteUser = async (userId: string) => {
setProcessingId(userId);
setError(null);
setDeleteConfirmUser(null);
try {
const res = await safeFetch('/api/users-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'delete',
targetUserId: userId
})
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Failed to delete user.');
}
// Remove from local list
setUsers(prev => prev.filter(u => u.id !== userId));
} catch (err: any) {
setError(err.message || 'Error deleting user.');
} finally {
setProcessingId(null);
}
};
const filteredUsers = users.filter(user =>
user.email.toLowerCase().includes(search.toLowerCase())
);
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleDateString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateStr;
}
};
return (
<div className="flex-1 flex flex-col h-full overflow-hidden bg-[#041021]">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-800">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-500/10 rounded-lg">
<Users className="w-6 h-6 text-blue-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Validación de Usuarios</h2>
<p className="text-sm text-slate-400">
Administra los accesos y los registros de usuarios en la plataforma.
</p>
</div>
</div>
<div className="relative">
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Buscar por email..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-slate-900 border border-slate-800 rounded-md pl-9 pr-4 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 w-64 transition-all"
/>
</div>
</div>
{/* Main Content Area */}
<div className="flex-1 overflow-auto p-6">
{error && (
<div className="mb-6 p-4 bg-red-950/30 border border-red-500/30 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-400 shrink-0 mt-0.5" />
<div className="flex-1 text-sm text-red-300">
<span className="font-semibold">Error:</span> {error}
</div>
<button
onClick={() => setError(null)}
className="text-red-400 hover:text-red-300 text-xs font-semibold px-2 py-1 rounded"
>
Descartar
</button>
</div>
)}
{warning && (
<div className="mb-6 p-4 bg-amber-950/30 border border-amber-500/30 rounded-lg flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
<div className="flex-1 text-sm text-amber-200">
<span className="font-semibold">Aviso:</span> {warning}
</div>
<button
onClick={() => setWarning(null)}
className="text-amber-300 hover:text-amber-200 text-xs font-semibold px-2 py-1 rounded"
>
Descartar
</button>
</div>
)}
{loading ? (
<div className="h-64 flex flex-col items-center justify-center text-slate-400">
<Loader2 className="w-8 h-8 animate-spin text-blue-500 mb-3" />
<p className="text-sm">Cargando lista de usuarios...</p>
</div>
) : filteredUsers.length === 0 ? (
<div className="h-64 flex flex-col items-center justify-center border border-dashed border-slate-800 rounded-xl text-slate-500">
<Users className="w-12 h-12 mb-3 opacity-20" />
{search ? (
<p className="text-sm">No se encontraron usuarios que coincidan con la búsqueda.</p>
) : (
<p className="text-sm">No hay registros de usuarios registrados.</p>
)}
</div>
) : (
<div className="bg-[#08152c] border border-slate-800/60 rounded-xl overflow-hidden shadow-xl">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-800/80 bg-slate-900/30 text-slate-400 text-xs font-semibold uppercase tracking-wider">
<th className="py-4 px-6">Email</th>
<th className="py-4 px-6">Fecha de Registro</th>
<th className="py-4 px-6">Estado</th>
<th className="py-4 px-6 text-right">Acciones</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50 text-slate-300 text-sm">
{filteredUsers.map(user => {
const isProcessing = processingId === user.id;
return (
<tr key={user.id} className="hover:bg-slate-900/10 transition-colors">
<td className="py-4 px-6 font-medium text-white max-w-xs truncate">
{user.email}
</td>
<td className="py-4 px-6 text-slate-400">
{formatDate(user.created_at)}
</td>
<td className="py-4 px-6">
{user.status === 'Master' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-indigo-500/10 text-indigo-400 border border-indigo-500/20">
<Shield className="w-3.5 h-3.5" />
Administrador Principal
</span>
) : user.status === 'Validated' ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-emerald-500/10 text-emerald-400 border border-emerald-500/20">
<CheckCircle className="w-3.5 h-3.5" />
Validado
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
<Clock className="w-3.5 h-3.5" />
Pendiente Validación
</span>
)}
</td>
<td className="py-4 px-6 text-right">
{user.status === 'Master' ? (
<span className="text-slate-500 text-xs italic">Protegido</span>
) : (
<div className="flex items-center justify-end gap-2">
{user.status === 'Pending' ? (
<button
onClick={() => handleToggleValidation(user, true)}
disabled={isProcessing}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white disabled:opacity-50 text-xs font-semibold rounded-md transition-colors shadow-sm"
>
{isProcessing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCircle className="w-3.5 h-3.5" />
)}
Validar
</button>
) : (
<button
onClick={() => handleToggleValidation(user, false)}
disabled={isProcessing}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-slate-800 hover:bg-slate-700 text-slate-300 disabled:opacity-50 text-xs font-semibold rounded-md border border-slate-700 transition-colors"
>
{isProcessing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<XCircle className="w-3.5 h-3.5" />
)}
Revocar
</button>
)}
<button
onClick={() => setDeleteConfirmUser(user)}
disabled={isProcessing}
className="inline-flex items-center justify-center p-1.5 bg-red-950/30 hover:bg-red-900/50 text-red-400 hover:text-red-300 border border-red-900/20 disabled:opacity-50 rounded-md transition-colors"
title="Eliminar usuario"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Confirmation Modal */}
{deleteConfirmUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-[#08152c] border border-slate-800 rounded-xl max-w-md w-full overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200">
<div className="p-6">
<div className="flex items-center gap-3 text-red-400 mb-4">
<Trash2 className="w-6 h-6 shrink-0" />
<h3 className="text-lg font-bold text-white">¿Eliminar usuario definitivamente?</h3>
</div>
<p className="text-sm text-slate-300 mb-2">
Estás a punto de eliminar la cuenta del usuario:
</p>
<p className="text-sm font-mono bg-slate-900/60 border border-slate-800/80 p-2.5 rounded text-blue-400 break-all mb-4">
{deleteConfirmUser.email}
</p>
<p className="text-xs text-red-400/90 leading-relaxed">
Esta acción no se puede deshacer. Se eliminarán sus accesos y toda su información asociada al servicio de autenticación.
</p>
</div>
<div className="bg-slate-900/50 border-t border-slate-800/60 px-6 py-4 flex items-center justify-end gap-3">
<button
onClick={() => setDeleteConfirmUser(null)}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-sm font-semibold rounded-md transition-colors"
>
Cancelar
</button>
<button
onClick={() => handleDeleteUser(deleteConfirmUser.id)}
className="px-4 py-2 bg-red-600 hover:bg-red-500 text-white text-sm font-semibold rounded-md transition-colors shadow-sm"
>
Eliminar Cuenta
</button>
</div>
</div>
</div>
)}
</div>
);
}
+23
View File
@@ -0,0 +1,23 @@
import React, { createContext, useContext, useMemo } from 'react';
import { COLUMNS, resolveColumnIndices } from '../types';
type ColumnIndices = typeof COLUMNS;
const ColumnsContext = createContext<ColumnIndices>(COLUMNS);
export function ColumnsProvider({ headers, children }: { headers: string[], children: React.ReactNode }) {
const resolved = useMemo(() => {
if (!headers || headers.length === 0) return COLUMNS;
return resolveColumnIndices(headers);
}, [headers]);
return (
<ColumnsContext.Provider value={resolved}>
{children}
</ColumnsContext.Provider>
);
}
export function useColumns() {
return useContext(ColumnsContext);
}
+43
View File
@@ -0,0 +1,43 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
interface FilterContextType {
states: Record<string, any>;
setState: (key: string, value: any) => void;
}
const FilterContext = createContext<FilterContextType | undefined>(undefined);
export function FilterProvider({ children }: { children: React.ReactNode }) {
const [states, setStates] = useState<Record<string, any>>({});
const setState = useCallback((key: string, value: any) => {
setStates(prev => ({
...prev,
[key]: typeof value === 'function' ? value(prev[key]) : value
}));
}, []);
return (
<FilterContext.Provider value={{ states, setState }}>
{children}
</FilterContext.Provider>
);
}
export function usePersistentState<T>(key: string, defaultValue: T): [T, (value: T | ((prev: T) => T)) => void] {
const context = useContext(FilterContext);
if (!context) {
throw new Error('usePersistentState must be used within a FilterProvider');
}
const state = context.states[key] !== undefined ? context.states[key] : defaultValue;
const setState = useCallback((value: T | ((prev: T) => T)) => {
context.setState(key, (current: any) => {
const actualCurrent = current !== undefined ? current : defaultValue;
return typeof value === 'function' ? (value as any)(actualCurrent) : value;
});
}, [context, key, defaultValue]);
return [state, setState];
}
+78
View File
@@ -4,6 +4,7 @@ const SESSION_KEY = 'craze_auth_session';
export interface AuthSession {
access_token: string;
refresh_token: string;
user: { id: string; email: string };
}
@@ -39,17 +40,94 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
}
const data = await response.json();
// Validate status before signing in
try {
const statusRes = await fetch('/api/users-admin', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${data.access_token}`
},
body: JSON.stringify({ action: 'check-status' })
});
if (!statusRes.ok) {
const err = await statusRes.json().catch(() => ({}));
throw new Error(err.error || 'Failed to verify account validation status.');
}
const statusData = await statusRes.json();
if (!statusData.validated) {
throw new Error('Tu usuario aún no ha sido validado por un administrador.');
}
} catch (err: any) {
throw new Error(err.message || 'Error de validación del usuario.');
}
const session: AuthSession = {
access_token: data.access_token,
refresh_token: data.refresh_token,
user: { id: data.user.id, email: data.user.email },
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
window.dispatchEvent(new Event('session-refreshed'));
return session;
}
let activeRefreshPromise: Promise<AuthSession> | null = null;
export async function refreshSession(refreshToken: string): Promise<AuthSession> {
if (activeRefreshPromise) {
return activeRefreshPromise;
}
activeRefreshPromise = (async () => {
try {
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=refresh_token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_ANON_KEY,
},
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
if (response.status === 400 || response.status === 401 || response.status === 403) {
localStorage.removeItem(SESSION_KEY);
window.dispatchEvent(new Event('session-refreshed'));
throw new Error('Session expired. Please sign in again.');
} else {
throw new Error(`Server error (${response.status}). Please try again later.`);
}
}
const data = await response.json();
const session: AuthSession = {
access_token: data.access_token,
refresh_token: data.refresh_token,
user: { id: data.user.id, email: data.user.email },
};
localStorage.setItem(SESSION_KEY, JSON.stringify(session));
window.dispatchEvent(new Event('session-refreshed'));
return session;
} catch (error) {
// If the error was not already thrown as "Session expired", we just propagate it.
throw error;
} finally {
activeRefreshPromise = null;
}
})();
return activeRefreshPromise;
}
export function signOut(): void {
localStorage.removeItem(SESSION_KEY);
window.dispatchEvent(new Event('session-refreshed'));
}
export function getStoredSession(): AuthSession | null {
+974
View File
@@ -0,0 +1,974 @@
import { ExcelRow, COLUMNS, resolveColumnIndices } from '../types';
import {
HistoryEntry,
DashboardDescriptionsSnapshot,
DashboardArticleDetailsSnapshot,
DashboardPricingSnapshot,
DashboardCosmeticSnapshot,
DashboardSnapshotTabs,
getDashboardSnapshotStore,
ensureDashboardSnapshot,
} from './supabase';
export type ControlTabId =
| 'control_dashboard'
| 'matrix'
| 'descriptions'
| 'article_details'
| 'dimensions'
| 'pricing'
| 'missing_data'
| 'cosmetic_items'
| 'pending_validation'
| 'history'
| 'user_management';
export type DashboardDrilldownTabId = Exclude<ControlTabId, 'control_dashboard'>;
export interface DashboardDrilldownRequest {
id: string;
tabId: DashboardDrilldownTabId;
focus: string;
}
export function createDashboardDrilldownRequest(tabId: DashboardDrilldownTabId, focus: string): DashboardDrilldownRequest {
return {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
tabId,
focus,
};
}
export interface TabMetricSnapshot {
total: number;
ok: number;
pending: number;
empty: number;
error: number;
}
export interface DescriptionsDashboardSnapshot {
total: number;
ok: number;
longDeMissing: number;
longEnMissing: number;
shortDeMissing: number;
shortEnMissing: number;
}
export interface ArticleDetailsDashboardSnapshot {
total: number;
ok: number;
detailsDeMissing: number;
detailsEnMissing: number;
}
export interface PricingDashboardSnapshot {
total: number;
ok: number;
itemToLogisticMissing: number;
uvpMissing: number;
srpIntMissing: number;
srpUkMissing: number;
unitsOuterMissing: number;
outerWMissing: number;
outerLMissing: number;
outerHMissing: number;
units40fMissing: number;
moqMissing: number;
weightIssues: number;
}
export interface CosmeticDashboardSnapshot {
total: number;
ok: number;
cpnpMissing: number;
}
export interface TabCardSummary {
id: ControlTabId;
label: string;
accentClass: string;
current: TabMetricSnapshot;
historical?: TabMetricSnapshot;
delta?: TabMetricSnapshot;
}
export interface PendingRowInfo {
rowIndex: number;
originalData: ExcelRow;
newData: ExcelRow;
articleName: string;
}
export interface HistorySyncRecord {
selected: boolean;
status: 'bc_pending' | 'previewed' | 'preview_only' | 'syncing' | 'synced' | 'failed';
previewToken?: string;
error?: string;
warning?: string;
}
export type HistorySyncMap = Record<string, HistorySyncRecord>;
export interface DashboardContext {
data: ExcelRow[];
pendingRows: Record<string, PendingRowInfo>;
rowStatuses: Record<string, string>;
historyEntries: HistoryEntry[];
historySyncMap?: HistorySyncMap;
}
export interface SnapshotStore {
[dateKey: string]: DashboardSnapshotTabs;
}
export const CONTROL_TABS: Array<{
id: Exclude<ControlTabId, 'control_dashboard'>;
label: string;
accentClass: string;
}> = [
{ id: 'matrix', label: 'Matrix', accentClass: 'border-sky-500/30 bg-sky-500/5' },
{ id: 'descriptions', label: 'Product Descriptions', accentClass: 'border-emerald-500/30 bg-emerald-500/5' },
{ id: 'article_details', label: 'Article Details', accentClass: 'border-indigo-500/30 bg-indigo-500/5' },
{ id: 'dimensions', label: 'Dimensions', accentClass: 'border-violet-500/30 bg-violet-500/5' },
{ id: 'pricing', label: 'Pricing & Units', accentClass: 'border-amber-500/30 bg-amber-500/5' },
{ id: 'missing_data', label: 'Missing Data', accentClass: 'border-rose-500/30 bg-rose-500/5' },
{ id: 'cosmetic_items', label: 'Cosmetic Items', accentClass: 'border-fuchsia-500/30 bg-fuchsia-500/5' },
{ id: 'pending_validation', label: 'Pending Validation', accentClass: 'border-orange-500/30 bg-orange-500/5' },
{ id: 'history', label: 'Change History', accentClass: 'border-cyan-500/30 bg-cyan-500/5' },
];
export const APP_TABS: Array<{
id: ControlTabId;
label: string;
accentClass: string;
}> = [
{ id: 'control_dashboard', label: 'Control Dashboard', accentClass: 'border-slate-500/30 bg-slate-500/5' },
...CONTROL_TABS,
];
const HISTORY_SYNC_STORAGE_KEY = 'history-bcSync';
const COSMETIC_LINES = new Set(['INKEE', 'BATH FUN', 'TOP FASHION', 'SENSES', 'BODYNESS']);
const STATUS_PENDING = new Set(['pending', 'bc_pending', 'queued', 'previewed', 'preview_only', 'syncing']);
const STATUS_ERROR = new Set(['failed', 'error']);
function normalize(value: unknown): string {
if (value === undefined || value === null) return '';
return String(value).replace(/\s+/g, ' ').trim();
}
function safeLocalStorageGet(key: string): string | null {
try {
return localStorage.getItem(key);
} catch {
return null;
}
}
function isBlank(value: unknown): boolean {
return normalize(value) === '';
}
function isDateLikeEmpty(value: unknown): boolean {
if (value === undefined || value === null || value === '') return true;
if (typeof value === 'number') return value === 0 || value === 1;
const text = normalize(value);
if (text === '' || text === '0' || text === '1') return true;
if (text === '0001-01-01' || text.startsWith('0001-01-01T')) return true;
if (text.endsWith('/1900')) return true;
return false;
}
function isNumericLikeEmpty(value: unknown): boolean {
if (value === undefined || value === null || value === '') return true;
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
return Number.isNaN(n) || n === 0;
}
function isTruthyNumeric(value: unknown): boolean {
if (value === undefined || value === null || value === '') return false;
const n = typeof value === 'number' ? value : Number(String(value).replace(',', '.'));
return !Number.isNaN(n) && n !== 0;
}
function toDate(value: unknown): Date | null {
if (isDateLikeEmpty(value)) return null;
if (typeof value === 'number') {
if (value >= 25569 && value <= 60000) {
const excelEpoch = new Date(1899, 11, 30);
return new Date(excelEpoch.getTime() + value * 86400000);
}
return null;
}
const text = normalize(value);
if (!text) return null;
const iso = new Date(text);
if (!Number.isNaN(iso.getTime())) return iso;
const parts = text.split('/');
if (parts.length === 3) {
const [dd, mm, yyyy] = parts;
const parsed = new Date(Number(yyyy), Number(mm) - 1, Number(dd));
if (!Number.isNaN(parsed.getTime())) return parsed;
}
return null;
}
function localDateKey(date: Date): string {
const y = date.getUTCFullYear();
const m = String(date.getUTCMonth() + 1).padStart(2, '0');
const d = String(date.getUTCDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function shiftDate(date: Date, days: number): Date {
const next = new Date(date);
next.setDate(next.getDate() - days);
return next;
}
function findIndicesByPatterns(headers: string[], patterns: string[][]): number[] {
const lower = headers.map(header => normalize(header).toLowerCase());
const indices = new Set<number>();
patterns.forEach(pattern => {
lower.forEach((header, index) => {
if (pattern.every(token => header.includes(token))) {
indices.add(index);
}
});
});
return Array.from(indices).sort((a, b) => a - b);
}
function unionIndices(...groups: number[][]): number[] {
const result = new Set<number>();
groups.forEach(group => group.forEach(index => result.add(index)));
return Array.from(result).sort((a, b) => a - b);
}
function getRowKey(row: ExcelRow): string {
return normalize(row[COLUMNS.ARTICLE_NO]);
}
export function getHistorySyncMapFromStorage(): HistorySyncMap {
try {
const raw = safeLocalStorageGet(HISTORY_SYNC_STORAGE_KEY);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
return parsed as HistorySyncMap;
} catch {
return {};
}
}
function getTabRows(tabId: ControlTabId, headers: string[], ctx: DashboardContext): ExcelRow[] {
if (tabId === 'pending_validation') {
return Object.values(ctx.pendingRows).map(row => row.newData);
}
if (tabId === 'history') {
return ctx.historyEntries.map(entry => entry.new_data);
}
const resolvedRows = ctx.data || [];
if (tabId === 'cosmetic_items') {
return resolvedRows.filter(row => COSMETIC_LINES.has(normalize(row[COLUMNS.LINE]).toUpperCase()));
}
if (tabId === 'missing_data') {
return resolvedRows.filter(row => isMissingDataRow(row, headers));
}
return resolvedRows;
}
function getDescriptionRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
const resolvedRows = ctx.data || [];
void headers;
return resolvedRows;
}
function getArticleDetailsRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
const resolvedRows = ctx.data || [];
void headers;
return resolvedRows;
}
function getPricingRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
const resolvedRows = ctx.data || [];
void headers;
return resolvedRows;
}
function getCosmeticRows(headers: string[], ctx: DashboardContext): ExcelRow[] {
const resolvedRows = ctx.data || [];
const columns = resolveColumnIndices(headers);
return resolvedRows.filter(row => COSMETIC_LINES.has(normalize(row[columns.LINE]).toUpperCase()));
}
function isMissingDataRow(row: ExcelRow, headers: string[]): boolean {
const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]);
const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]);
const classificationIdx = findHeaderIndexFromHeaders(headers, [['classification']]);
const launch = launchIdx >= 0 ? row[launchIdx] : undefined;
const ready = readyIdx >= 0 ? row[readyIdx] : undefined;
const classification = classificationIdx >= 0 ? row[classificationIdx] : undefined;
const missingBasics = isDateLikeEmpty(launch) || isBlank(classification);
const readyBeforeLaunch = (() => {
const launchDate = toDate(launch);
const readyDate = toDate(ready);
if (!launchDate || !readyDate) return false;
return readyDate.getTime() > launchDate.getTime();
})();
const upcomingLaunch = (() => {
const launchDate = toDate(launch);
if (!launchDate) return false;
const today = new Date();
today.setHours(0, 0, 0, 0);
const diff = Math.ceil((launchDate.getTime() - today.getTime()) / 86400000);
return diff > 0 && diff <= 180;
})();
return missingBasics || readyBeforeLaunch || upcomingLaunch;
}
function findHeaderIndexFromHeaders(headers: string[], patterns: string[][]): number {
return findIndicesByPatterns(headers, patterns)[0] ?? -1;
}
function getEditableIndices(tabId: ControlTabId, headers: string[]): number[] {
const columns = resolveColumnIndices(headers);
const descriptions = unionIndices(
[columns.LONG_DE, columns.LONG_EN, columns.SHORT_DE, columns.SHORT_EN].filter(i => typeof i === 'number' && i >= 0),
findIndicesByPatterns(headers, [['long', 'description']]),
findIndicesByPatterns(headers, [['short', 'description']]),
);
const articleDetails = unionIndices(
[columns.DETAILS_EN, columns.DETAILS_DE, columns.SHORT_DE, columns.SHORT_EN, columns.MOQ, columns.CPNP_NO].filter(i => typeof i === 'number' && i >= 0),
findIndicesByPatterns(headers, [['article', 'details', 'english']]),
findIndicesByPatterns(headers, [['article', 'details', 'german']]),
findIndicesByPatterns(headers, [['launch', 'date']]),
findIndicesByPatterns(headers, [['ready', 'to', 'order', 'date']]),
findIndicesByPatterns(headers, [['moq']]),
findIndicesByPatterns(headers, [['cpnp']])
);
const dimensions = unionIndices(
[columns.UNITS_OUTER, columns.INNER_W, columns.INNER_L, columns.INNER_H, columns.OUTER_W, columns.OUTER_L, columns.OUTER_H, columns.MOQ].filter(i => typeof i === 'number' && i >= 0),
findIndicesByPatterns(headers, [['inner', 'w']]),
findIndicesByPatterns(headers, [['inner', 'l']]),
findIndicesByPatterns(headers, [['inner', 'h']]),
findIndicesByPatterns(headers, [['outer', 'w']]),
findIndicesByPatterns(headers, [['outer', 'l']]),
findIndicesByPatterns(headers, [['outer', 'h']]),
findIndicesByPatterns(headers, [['units', 'outer']]),
findIndicesByPatterns(headers, [['moq']])
);
const pricing = unionIndices(
[columns.UNITS_OUTER, columns.OUTER_W, columns.OUTER_L, columns.OUTER_H, columns.MOQ].filter(i => typeof i === 'number' && i >= 0),
findIndicesByPatterns(headers, [['uvp']]),
findIndicesByPatterns(headers, [['srp']]),
findIndicesByPatterns(headers, [['price']]),
findIndicesByPatterns(headers, [['cost']]),
findIndicesByPatterns(headers, [['net']]),
findIndicesByPatterns(headers, [['gross']]),
findIndicesByPatterns(headers, [['units', 'outer']]),
findIndicesByPatterns(headers, [['outer', 'w']]),
findIndicesByPatterns(headers, [['outer', 'l']]),
findIndicesByPatterns(headers, [['outer', 'h']]),
findIndicesByPatterns(headers, [['moq']])
);
const missingData = unionIndices(
findIndicesByPatterns(headers, [['classification']]),
findIndicesByPatterns(headers, [['launch', 'date']]),
findIndicesByPatterns(headers, [['ready', 'to', 'order', 'date']])
);
const cosmetic = findIndicesByPatterns(headers, [['cpnp']]);
const allEditable = unionIndices(descriptions, articleDetails, dimensions, pricing, missingData, cosmetic);
switch (tabId) {
case 'descriptions':
return descriptions;
case 'article_details':
return articleDetails;
case 'dimensions':
return dimensions;
case 'pricing':
return pricing;
case 'missing_data':
return missingData;
case 'cosmetic_items':
return cosmetic;
case 'pending_validation':
case 'history':
case 'matrix':
default:
return allEditable;
}
}
function isFieldEmptyForTab(tabId: ControlTabId, index: number, value: unknown): boolean {
if (tabId === 'descriptions' || tabId === 'article_details' || tabId === 'cosmetic_items' || tabId === 'history' || tabId === 'pending_validation' || tabId === 'matrix') {
if (index === COLUMNS.CPNP_NO) return isBlank(value);
if (index === COLUMNS.MOQ || index === COLUMNS.UNITS_OUTER || index === COLUMNS.INNER_W || index === COLUMNS.INNER_L || index === COLUMNS.INNER_H || index === COLUMNS.OUTER_W || index === COLUMNS.OUTER_L || index === COLUMNS.OUTER_H) {
return isNumericLikeEmpty(value);
}
if (tabId === 'descriptions' && (index === COLUMNS.LONG_DE || index === COLUMNS.LONG_EN || index === COLUMNS.SHORT_DE || index === COLUMNS.SHORT_EN)) {
return isBlank(value);
}
if (tabId === 'article_details' && (index === COLUMNS.DETAILS_DE || index === COLUMNS.DETAILS_EN || index === COLUMNS.SHORT_DE || index === COLUMNS.SHORT_EN || index === COLUMNS.MOQ || index === COLUMNS.CPNP_NO)) {
return isBlank(value);
}
if (index === COLUMNS.ARTICLE_NO || index === COLUMNS.ARTICLE_NAME || index === COLUMNS.LINE || index === COLUMNS.CLASSIFICATION) {
return isBlank(value);
}
}
if (tabId === 'missing_data') {
return index === COLUMNS.CLASSIFICATION || index === COLUMNS.CPNP_NO ? isBlank(value) : isDateLikeEmpty(value);
}
if (tabId === 'pricing' || tabId === 'dimensions') {
return isNumericLikeEmpty(value) || isBlank(value);
}
return isBlank(value);
}
function countEmptyRows(tabId: ControlTabId, rows: ExcelRow[], headers: string[]): number {
const indices = getEditableIndices(tabId, headers);
return rows.filter(row => indices.some(index => isFieldEmptyForTab(tabId, index, row[index]))).length;
}
function rowHasPendingStatus(articleNo: string, ctx: DashboardContext): boolean {
const normalized = normalize(ctx.rowStatuses[articleNo]).toLowerCase();
return STATUS_PENDING.has(normalized);
}
function countPendingRows(tabId: ControlTabId, rows: ExcelRow[], ctx: DashboardContext): number {
if (tabId === 'pending_validation') {
return rows.length;
}
if (tabId === 'history') {
return ctx.historyEntries.filter(entry => {
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
return STATUS_PENDING.has(status);
}).length;
}
return rows.filter(row => {
const articleNo = getRowKey(row);
return rowHasPendingStatus(articleNo, ctx) || Object.prototype.hasOwnProperty.call(ctx.pendingRows, articleNo);
}).length;
}
function collectPendingArticles(tabId: ControlTabId, rows: ExcelRow[], ctx: DashboardContext): Set<string> {
const articles = new Set<string>();
if (tabId === 'pending_validation') {
rows.forEach(row => {
const articleNo = getRowKey(row);
if (articleNo) articles.add(articleNo);
});
return articles;
}
if (tabId === 'history') {
ctx.historyEntries.forEach(entry => {
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
if (STATUS_PENDING.has(status)) {
articles.add(normalize(entry.product_id));
}
});
return articles;
}
rows.forEach(row => {
const articleNo = getRowKey(row);
if (!articleNo) return;
if (rowHasPendingStatus(articleNo, ctx) || Object.prototype.hasOwnProperty.call(ctx.pendingRows, articleNo)) {
articles.add(articleNo);
}
});
return articles;
}
function collectEmptyArticles(tabId: ControlTabId, rows: ExcelRow[], headers: string[]): Set<string> {
const indices = getEditableIndices(tabId, headers);
const articles = new Set<string>();
rows.forEach(row => {
const articleNo = getRowKey(row);
if (!articleNo) return;
if (indices.some(index => isFieldEmptyForTab(tabId, index, row[index]))) {
articles.add(articleNo);
}
});
return articles;
}
function collectErrorArticles(tabId: ControlTabId, rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
switch (tabId) {
case 'dimensions':
return collectDimensionErrorArticles(rows, headers, ctx);
case 'pricing':
return collectPricingErrorArticles(rows, headers, ctx);
case 'missing_data':
return collectMissingDataErrorArticles(rows, headers, ctx);
case 'history':
return new Set(
ctx.historyEntries
.filter(entry => {
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
return status === 'failed';
})
.map(entry => normalize(entry.product_id))
.filter(Boolean)
);
case 'matrix':
return new Set([
...collectPricingErrorArticles(rows, headers, ctx),
...collectDimensionErrorArticles(rows, headers, ctx),
...collectMissingDataErrorArticles(rows, headers, ctx),
...rows.filter(row => hasRowStatusError(getRowKey(row), ctx)).map(row => getRowKey(row)),
]);
default:
return new Set(
rows
.filter(row => hasRowStatusError(getRowKey(row), ctx))
.map(row => getRowKey(row))
.filter(Boolean)
);
}
}
function computeDescriptionsSnapshot(headers: string[], ctx: DashboardContext): DescriptionsDashboardSnapshot {
const rows = getDescriptionRows(headers, ctx);
const columns = resolveColumnIndices(headers);
const longDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.LONG_DE]) ? 1 : 0), 0);
const longEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.LONG_EN]) ? 1 : 0), 0);
const shortDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.SHORT_DE]) ? 1 : 0), 0);
const shortEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.SHORT_EN]) ? 1 : 0), 0);
const ok = rows.reduce((count, row) => {
const complete = !isBlank(row[columns.LONG_DE])
&& !isBlank(row[columns.LONG_EN])
&& !isBlank(row[columns.SHORT_DE])
&& !isBlank(row[columns.SHORT_EN]);
return count + (complete ? 1 : 0);
}, 0);
return {
total: rows.length,
ok,
longDeMissing,
longEnMissing,
shortDeMissing,
shortEnMissing,
};
}
function computeArticleDetailsSnapshot(headers: string[], ctx: DashboardContext): ArticleDetailsDashboardSnapshot {
const rows = getArticleDetailsRows(headers, ctx);
const columns = resolveColumnIndices(headers);
const detailsDeMissing = rows.reduce((count, row) => count + (isBlank(row[columns.DETAILS_DE]) ? 1 : 0), 0);
const detailsEnMissing = rows.reduce((count, row) => count + (isBlank(row[columns.DETAILS_EN]) ? 1 : 0), 0);
const ok = rows.reduce((count, row) => {
const complete = !isBlank(row[columns.DETAILS_DE]) && !isBlank(row[columns.DETAILS_EN]);
return count + (complete ? 1 : 0);
}, 0);
return {
total: rows.length,
ok,
detailsDeMissing,
detailsEnMissing,
};
}
function findHeaderIndexByName(headers: string[], predicate: (name: string) => boolean): number {
return headers.findIndex(header => predicate(normalize(header).toLowerCase()));
}
function isWeightIssueValue(value: unknown): boolean {
return !isBlank(value);
}
function computePricingSnapshot(headers: string[], ctx: DashboardContext): PricingDashboardSnapshot {
const rows = getPricingRows(headers, ctx);
const columns = resolveColumnIndices(headers);
const articleIndex = columns.ARTICLE_NO;
const skuForRow = (row: ExcelRow) => normalize(row[articleIndex]);
const uvpIdx = findHeaderIndexFromHeaders(headers, [['uvp']]);
const srpHeaders = headers
.map((header, index) => ({ index, text: normalize(header).toLowerCase() }))
.filter(({ text }) => text.includes('srp'));
const srpIntIdx = srpHeaders.find(({ text }) => text.includes('int'))?.index ?? -1;
const srpUkIdx = srpHeaders.find(({ text }) => text.includes('uk'))?.index ?? -1;
const units40fIdx = findHeaderIndexFromHeaders(headers, [['40f']]);
const itemToLogisticIdx = columns.ITEM_TO_LOGISTIC;
const unitsOuterIdx = columns.UNITS_OUTER;
const outerWIdx = columns.OUTER_W;
const outerLIdx = columns.OUTER_L;
const outerHIdx = columns.OUTER_H;
const moqIdx = columns.MOQ;
const nwIdx = findHeaderIndexFromHeaders(headers, [['nw']]);
const gwIdx = findHeaderIndexFromHeaders(headers, [['gw']]);
const rowIssues = new Set<string>();
const itemToLogisticMissing = rows.reduce((count, row) => {
const missing = isBlank(row[itemToLogisticIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const uvpMissing = rows.reduce((count, row) => {
const missing = uvpIdx < 0 ? true : isBlank(row[uvpIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const srpIntMissing = rows.reduce((count, row) => {
const missing = srpIntIdx < 0 ? true : isBlank(row[srpIntIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const srpUkMissing = rows.reduce((count, row) => {
const missing = srpUkIdx < 0 ? true : isBlank(row[srpUkIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const unitsOuterMissing = rows.reduce((count, row) => {
const missing = isNumericLikeEmpty(row[unitsOuterIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const outerWMissing = rows.reduce((count, row) => {
const missing = isNumericLikeEmpty(row[outerWIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const outerLMissing = rows.reduce((count, row) => {
const missing = isNumericLikeEmpty(row[outerLIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const outerHMissing = rows.reduce((count, row) => {
const missing = isNumericLikeEmpty(row[outerHIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const units40fMissing = rows.reduce((count, row) => {
const missing = units40fIdx < 0 ? true : isNumericLikeEmpty(row[units40fIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const moqMissing = rows.reduce((count, row) => {
const missing = isNumericLikeEmpty(row[moqIdx]);
if (missing) rowIssues.add(skuForRow(row));
return count + (missing ? 1 : 0);
}, 0);
const weightIssues = rows.reduce((count, row) => {
let issue = false;
if (nwIdx >= 0 && gwIdx >= 0) {
const nw = parseFloat(String(row[nwIdx] ?? '').replace(',', '.'));
const gw = parseFloat(String(row[gwIdx] ?? '').replace(',', '.'));
issue = !Number.isNaN(nw) && !Number.isNaN(gw) && nw > gw;
}
if (issue) rowIssues.add(skuForRow(row));
return count + (issue ? 1 : 0);
}, 0);
const total = rows.length;
return {
total,
ok: Math.max(total - rowIssues.size, 0),
itemToLogisticMissing,
uvpMissing,
srpIntMissing,
srpUkMissing,
unitsOuterMissing,
outerWMissing,
outerLMissing,
outerHMissing,
units40fMissing,
moqMissing,
weightIssues,
};
}
function computeCosmeticSnapshot(headers: string[], ctx: DashboardContext): CosmeticDashboardSnapshot {
const rows = getCosmeticRows(headers, ctx);
const columns = resolveColumnIndices(headers);
const cpnpPresent = rows.reduce((count, row) => count + (!isBlank(row[columns.CPNP_NO]) ? 1 : 0), 0);
const cpnpMissing = rows.length - cpnpPresent;
return {
total: rows.length,
ok: cpnpPresent,
cpnpMissing,
};
}
function hasRowStatusError(articleNo: string, ctx: DashboardContext): boolean {
const status = normalize(ctx.rowStatuses[articleNo]).toLowerCase();
return STATUS_ERROR.has(status);
}
function countDimensionErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
return collectDimensionErrorArticles(rows, headers, ctx).size;
}
function collectDimensionErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
const columns = resolveColumnIndices(headers);
const groups = new Map<string, ExcelRow[]>();
rows.forEach(row => {
const innerValues = [row[columns.INNER_L], row[columns.INNER_W], row[columns.INNER_H]];
if (innerValues.every(value => isNumericLikeEmpty(value) || isBlank(value))) return;
const innerKey = innerValues
.map(value => normalize(value) || '0')
.join('x');
if (!groups.has(innerKey)) groups.set(innerKey, []);
groups.get(innerKey)!.push(row);
});
const errorArticles = new Set<string>();
groups.forEach(groupRows => {
if (groupRows.length <= 1) return;
const signature = (row: ExcelRow) => [
row[columns.OUTER_L],
row[columns.OUTER_W],
row[columns.OUTER_H],
row[columns.UNITS_OUTER],
row[columns.MOQ],
].map(value => normalize(value) || '0').join('|');
const firstSignature = signature(groupRows[0]);
const inconsistent = groupRows.some(row => signature(row) !== firstSignature);
if (!inconsistent) return;
groupRows.forEach(row => {
const articleNo = getRowKey(row);
if (articleNo) errorArticles.add(articleNo);
});
});
return errorArticles;
}
function countPricingErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
return collectPricingErrorArticles(rows, headers, ctx).size;
}
function collectPricingErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
const columns = resolveColumnIndices(headers);
const uvpIdx = findHeaderIndexFromHeaders(headers, [['uvp']]);
const srpIndices = findIndicesByPatterns(headers, [['srp']]);
const netIdx = findHeaderIndexFromHeaders(headers, [['net']]);
const grossIdx = findHeaderIndexFromHeaders(headers, [['gross']]);
const articles = new Set<string>();
rows.forEach(row => {
const articleNo = getRowKey(row);
if (hasRowStatusError(articleNo, ctx)) {
if (articleNo) articles.add(articleNo);
return;
}
const pricingMissing = uvpIdx >= 0 && isBlank(row[uvpIdx]);
const srpMissing = srpIndices.some(index => isBlank(row[index]));
const unitsOuter = row[columns.UNITS_OUTER];
const outerW = row[columns.OUTER_W];
const outerL = row[columns.OUTER_L];
const outerH = row[columns.OUTER_H];
const unitsError = isNumericLikeEmpty(unitsOuter) || normalize(unitsOuter) === '1';
const outerError = [outerW, outerL, outerH].some(value => isNumericLikeEmpty(value) || normalize(value) === '1');
const weightError = netIdx >= 0 && grossIdx >= 0 && !isBlank(row[netIdx]) && !isBlank(row[grossIdx]) && Number(String(row[netIdx]).replace(',', '.')) > Number(String(row[grossIdx]).replace(',', '.'));
if (pricingMissing || srpMissing || unitsError || outerError || weightError) {
if (articleNo) articles.add(articleNo);
}
});
return articles;
}
function countMissingDataErrors(rows: ExcelRow[], headers: string[], ctx: DashboardContext): number {
return collectMissingDataErrorArticles(rows, headers, ctx).size;
}
function collectMissingDataErrorArticles(rows: ExcelRow[], headers: string[], ctx: DashboardContext): Set<string> {
const columns = resolveColumnIndices(headers);
const launchIdx = findHeaderIndexFromHeaders(headers, [['launch', 'date']]);
const readyIdx = findHeaderIndexFromHeaders(headers, [['ready', 'to', 'order', 'date']]);
const articles = new Set<string>();
rows.forEach(row => {
const articleNo = getRowKey(row);
if (hasRowStatusError(articleNo, ctx)) {
if (articleNo) articles.add(articleNo);
return;
}
if (launchIdx < 0 || readyIdx < 0) return;
const launch = toDate(row[launchIdx]);
const ready = toDate(row[readyIdx]);
if (!launch || !ready) return;
if (ready.getTime() > launch.getTime() || isNumericLikeEmpty(row[columns.MOQ])) {
if (articleNo) articles.add(articleNo);
}
});
return articles;
}
function countGenericErrors(rows: ExcelRow[], ctx: DashboardContext, extraPredicate?: (row: ExcelRow) => boolean): number {
return rows.filter(row => {
const articleNo = getRowKey(row);
if (hasRowStatusError(articleNo, ctx)) return true;
return extraPredicate ? extraPredicate(row) : false;
}).length;
}
function countHistoryErrors(entries: HistoryEntry[], ctx: DashboardContext): number {
return entries.filter(entry => {
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
return status === 'failed';
}).length;
}
function countHistoryEmptyRows(entries: HistoryEntry[], headers: string[]): number {
const indices = getEditableIndices('history', headers);
return entries.filter(entry => indices.some(index => isFieldEmptyForTab('history', index, entry.new_data?.[index]))).length;
}
function countHistoryPendingRows(entries: HistoryEntry[], ctx: DashboardContext): number {
return entries.filter(entry => {
const key = normalize(entry.id || `${entry.product_id}-${entry.changed_at}`);
const status = normalize(ctx.historySyncMap?.[key]?.status || getHistorySyncMapFromStorage()[key]?.status || 'bc_pending').toLowerCase();
return STATUS_PENDING.has(status);
}).length;
}
function createCurrentSnapshot(tabId: ControlTabId, rows: ExcelRow[], headers: string[], ctx: DashboardContext): TabMetricSnapshot {
const total = rows.length;
const pendingSet = collectPendingArticles(tabId, rows, ctx);
const emptySet = collectEmptyArticles(tabId, rows, headers);
const errorSet = collectErrorArticles(tabId, rows, headers, ctx);
const issueSet = new Set<string>([...pendingSet, ...emptySet, ...errorSet]);
switch (tabId) {
case 'dimensions':
case 'pricing':
case 'missing_data':
case 'history':
case 'pending_validation':
case 'matrix':
break;
default:
break;
}
return {
total,
ok: Math.max(total - issueSet.size, 0),
pending: pendingSet.size,
empty: emptySet.size,
error: errorSet.size,
};
}
export function computeControlDashboardSummaries(headers: string[], ctx: DashboardContext): TabCardSummary[] {
const historySyncMap = ctx.historySyncMap || getHistorySyncMapFromStorage();
const effectiveCtx: DashboardContext = { ...ctx, historySyncMap };
return CONTROL_TABS.map(tab => {
const rows = getTabRows(tab.id, headers, effectiveCtx);
const current = createCurrentSnapshot(tab.id, rows, headers, effectiveCtx);
return {
id: tab.id,
label: tab.label,
accentClass: tab.accentClass,
current,
};
});
}
export function computeDescriptionsDashboardSnapshot(headers: string[], ctx: DashboardContext): DescriptionsDashboardSnapshot {
return computeDescriptionsSnapshot(headers, ctx);
}
export function computeArticleDetailsDashboardSnapshot(headers: string[], ctx: DashboardContext): ArticleDetailsDashboardSnapshot {
return computeArticleDetailsSnapshot(headers, ctx);
}
export function computePricingDashboardSnapshot(headers: string[], ctx: DashboardContext): PricingDashboardSnapshot {
return computePricingSnapshot(headers, ctx);
}
export function computeCosmeticDashboardSnapshot(headers: string[], ctx: DashboardContext): CosmeticDashboardSnapshot {
return computeCosmeticSnapshot(headers, ctx);
}
export function getDaysAgoKey(days: number, date = new Date()): string {
return localDateKey(shiftDate(date, days));
}
export function diffSnapshots(current: TabMetricSnapshot, historical?: TabMetricSnapshot): TabMetricSnapshot | undefined {
if (!historical) return undefined;
return {
total: current.total - historical.total,
ok: current.ok - historical.ok,
pending: current.pending - historical.pending,
empty: current.empty - historical.empty,
error: current.error - historical.error,
};
}
export async function loadDashboardSnapshots(): Promise<Record<string, DashboardSnapshotTabs>> {
const store = await getDashboardSnapshotStore();
return store;
}
export async function ensureDailyDashboardSnapshot(date: Date, snapshot: DashboardSnapshotTabs): Promise<void> {
const key = localDateKey(date);
await ensureDashboardSnapshot(key, {
...snapshot,
});
}
+417 -82
View File
@@ -1,59 +1,119 @@
import { refreshSession, getStoredSession } from './auth';
import { COLUMNS } from '../types';
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const LEGACY_CPNP_INDEX = 77;
export async function safeFetch(url: string, options: RequestInit = {}): Promise<Response> {
const session = getStoredSession();
const token = session?.access_token || SUPABASE_ANON_KEY;
const headers = {
...options.headers,
'apikey': SUPABASE_ANON_KEY,
'Authorization': `Bearer ${token}`,
};
let response = await fetch(url, { ...options, headers });
if (response.status === 401) {
const latestSession = getStoredSession();
if (latestSession) {
// If the access token in localStorage is already different (newer) than the one we used,
// try retrying the request with that new token first without doing a refresh.
if (latestSession.access_token !== token) {
const retryHeaders = {
...options.headers,
'apikey': SUPABASE_ANON_KEY,
'Authorization': `Bearer ${latestSession.access_token}`,
};
response = await fetch(url, { ...options, headers: retryHeaders });
if (response.status !== 401) {
return response;
}
}
// If we still get a 401, perform the refresh using the latest refresh token
if (latestSession.refresh_token) {
try {
const newSession = await refreshSession(latestSession.refresh_token);
const newHeaders = {
...options.headers,
'apikey': SUPABASE_ANON_KEY,
'Authorization': `Bearer ${newSession.access_token}`,
};
response = await fetch(url, { ...options, headers: newHeaders });
} catch (refreshError) {
console.error('Session refresh failed:', refreshError);
}
}
}
}
return response;
}
export interface ExcelRow extends Array<any> {}
export interface SyncedRow {
data: ExcelRow;
status?: 'pending' | 'synced';
status?: 'pending' | 'edited' | 'synced' | 'excel';
updated_at?: string;
}
export async function getAllSyncedRows(token?: string): Promise<Record<string, SyncedRow>> {
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
const PAGE_SIZE = 1000;
const result: Record<string, SyncedRow> = {};
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=updated_at.desc`,
{
cache: 'no-store',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
let offset = 0;
// Paginate until Supabase returns fewer rows than PAGE_SIZE (no more pages).
// Supabase REST caps a single response at 1000 rows, so we must page.
// Safety cap at 20 pages (20k products) to avoid infinite loops.
for (let page = 0; page < 20; page++) {
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products?select=product_id,data,status,updated_at&order=product_id.asc&limit=${PAGE_SIZE}&offset=${offset}`,
{ cache: 'no-store' }
);
if (!response.ok) {
if (!response.ok) {
const errText = await response.text();
console.error('getAllSyncedRows failed:', response.status, errText);
return {};
}
const rows = await response.json();
const result: Record<string, SyncedRow> = {};
for (const row of rows) {
result[row.product_id] = { data: row.data, status: row.status };
return result;
}
const rows = await response.json();
for (const row of rows) {
const current = result[row.product_id];
const currentUpdatedAt = current?.updated_at ? Date.parse(current.updated_at) : -1;
const nextUpdatedAt = row.updated_at ? Date.parse(row.updated_at) : -1;
if (!current || nextUpdatedAt >= currentUpdatedAt) {
result[row.product_id] = { data: row.data, status: row.status, updated_at: row.updated_at };
}
}
if (rows.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
return result;
} catch (error) {
console.error('Error fetching synced rows from Supabase:', error);
return {};
return result;
}
}
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, token?: string): Promise<{ success: boolean; error?: string }> {
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, status: 'pending' | 'edited' | 'synced' = 'pending'): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products`,
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products?on_conflict=product_id`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'resolution=merge-duplicates'
'Prefer': 'resolution=merge-duplicates,return=minimal',
},
body: JSON.stringify({
product_id: articleNo,
data: rowData,
status: 'synced',
status,
updated_at: new Date().toISOString()
})
}
@@ -75,7 +135,7 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, to
}
export interface HistoryEntry {
id?: number;
id?: string;
product_id: string;
article_name: string;
old_data: ExcelRow;
@@ -84,24 +144,90 @@ export interface HistoryEntry {
changed_at: string;
}
export interface DashboardDescriptionsSnapshot {
total: number;
ok: number;
longDeMissing: number;
longEnMissing: number;
shortDeMissing: number;
shortEnMissing: number;
}
export interface DashboardArticleDetailsSnapshot {
total: number;
ok: number;
detailsDeMissing: number;
detailsEnMissing: number;
}
export interface DashboardPricingSnapshot {
total: number;
ok: number;
itemToLogisticMissing: number;
uvpMissing: number;
srpIntMissing: number;
srpUkMissing: number;
unitsOuterMissing: number;
outerWMissing: number;
outerLMissing: number;
outerHMissing: number;
units40fMissing: number;
moqMissing: number;
weightIssues: number;
}
export interface DashboardCosmeticSnapshot {
total: number;
ok: number;
cpnpMissing: number;
}
export interface DashboardSnapshotTabs {
descriptions?: DashboardDescriptionsSnapshot;
articleDetails?: DashboardArticleDetailsSnapshot;
pricing?: DashboardPricingSnapshot;
cosmeticItems?: DashboardCosmeticSnapshot;
}
const CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID = '__control_dashboard__';
function normalizeHistoryValue(value: any): any {
if (value === undefined || value === null || value === '') return null;
return value;
}
function valuesEqual(a: any, b: any): boolean {
return normalizeHistoryValue(a) === normalizeHistoryValue(b);
}
function getChangedIndices(oldData: ExcelRow = [], newData: ExcelRow = []): number[] {
const maxLen = Math.max(oldData.length, newData.length);
const changed: number[] = [];
for (let i = 0; i < maxLen; i++) {
if (!valuesEqual(oldData[i], newData[i])) {
changed.push(i);
}
}
return changed;
}
export async function saveHistoryEntry(
productId: string,
articleName: string,
oldData: ExcelRow,
newData: ExcelRow,
changedBy: string,
token?: string
changedBy: string
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'return=minimal'
'Prefer': 'return=minimal',
},
body: JSON.stringify({
product_id: productId,
@@ -129,58 +255,269 @@ export async function saveHistoryEntry(
}
}
export async function getHistory(token?: string): Promise<HistoryEntry[]> {
export async function getHistory(): Promise<HistoryEntry[]> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
{
cache: 'no-store',
headers: {
'apikey': SUPABASE_KEY,
// Try using only the ANON key just in case RLS or token expiry is failing silently
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
const PAGE_SIZE = 1000;
const allRows: HistoryEntry[] = [];
for (let page = 0; page < 20; page++) {
const offset = page * PAGE_SIZE;
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
{ cache: 'no-store' }
);
if (!response.ok) {
const err = await response.text();
return [{
id: 'error-' + Date.now(),
product_id: 'ERROR',
article_name: `Failed: ${response.status} ${err}`,
old_data: [],
new_data: [],
changed_at: new Date().toISOString(),
changed_by: 'system'
}];
if (!response.ok) {
const errText = await response.text();
console.error('[getHistory] Error:', response.status, errText.substring(0, 200));
return [{
id: 'ERROR',
product_id: 'ERROR',
article_name: `Failed: ${response.status} ${errText.substring(0, 200)}`,
old_data: [],
new_data: [],
changed_at: new Date().toISOString(),
changed_by: 'system'
}];
}
const batch: HistoryEntry[] = await response.json();
allRows.push(...batch.filter(entry => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID));
if (batch.length < PAGE_SIZE) break;
}
return await response.json();
// Return oldest-first so index+1 = natural chronological number
return allRows;
} catch (error: any) {
console.error('Error fetching history:', error);
console.error('[getHistory] Exception:', error.message);
return [{
id: 'error-' + Date.now(),
product_id: 'EXCEPTION',
article_name: `Message: ${error.message}`,
old_data: [],
new_data: [],
changed_at: new Date().toISOString(),
changed_by: 'system'
id: 'EXCEPTION',
product_id: 'EXCEPTION',
article_name: `Message: ${error.message}`,
old_data: [],
new_data: [],
changed_at: new Date().toISOString(),
changed_by: 'system'
}];
}
}
export async function deleteHistoryEntry(id: string, token?: string): Promise<boolean> {
export async function getHistoryDataForMerge(includeLegacyCpnpIndex = true): Promise<Record<string, ExcelRow>> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(id)}`,
{
method: 'DELETE',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
}
const PAGE_SIZE = 1000;
const entries: HistoryEntry[] = [];
for (let page = 0; page < 20; page++) {
const offset = page * PAGE_SIZE;
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history?select=product_id,old_data,new_data,changed_at,id&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
{ cache: 'no-store' }
);
if (!response.ok) {
console.error('[getHistoryDataForMerge] Error:', response.status);
return {};
}
const batch: HistoryEntry[] = await response.json();
entries.push(
...batch.filter((entry: HistoryEntry) => entry.product_id !== CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)
);
if (batch.length < PAGE_SIZE) break;
}
entries.sort((a, b) => {
const timeDelta = new Date(a.changed_at).getTime() - new Date(b.changed_at).getTime();
if (timeDelta !== 0) return timeDelta;
return String(a.id || '').localeCompare(String(b.id || ''));
});
const result: Record<string, ExcelRow> = {};
for (const entry of entries) {
if (!entry.product_id) continue;
if (!result[entry.product_id]) {
result[entry.product_id] = [];
}
const target = result[entry.product_id];
const changedIndices = getChangedIndices(entry.old_data || [], entry.new_data || []);
for (const idx of changedIndices) {
target[idx] = entry.new_data?.[idx];
}
// CPNP values were saved through multiple code paths over time.
// Preserve the latest non-empty value even when a given history row
// does not surface it as a changed index. Index 77 is only a CPNP
// location in the pre-July-2026 layout (includeLegacyCpnpIndex);
// in the new layout it holds "Item To Root Units".
const latestCpnp =
entry.new_data?.[COLUMNS.CPNP_NO] ??
(includeLegacyCpnpIndex ? entry.new_data?.[LEGACY_CPNP_INDEX] : undefined) ??
entry.old_data?.[COLUMNS.CPNP_NO];
const fallbackCpnp =
latestCpnp !== undefined && latestCpnp !== null && latestCpnp !== ''
? latestCpnp
: includeLegacyCpnpIndex
? entry.old_data?.[LEGACY_CPNP_INDEX]
: undefined;
if (fallbackCpnp !== undefined && fallbackCpnp !== null && fallbackCpnp !== '') {
target[COLUMNS.CPNP_NO] = fallbackCpnp;
}
}
return result;
} catch (error: any) {
console.error('[getHistoryDataForMerge] Exception:', error.message);
return {};
}
}
export async function getDashboardSnapshotStore(): Promise<Record<string, DashboardSnapshotTabs>> {
try {
const PAGE_SIZE = 1000;
const rows: Array<{ changed_at: string; new_data: any }> = [];
for (let page = 0; page < 20; page++) {
const offset = page * PAGE_SIZE;
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history?select=changed_at,new_data,product_id&product_id=eq.${encodeURIComponent(CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)}&order=changed_at.asc&limit=${PAGE_SIZE}&offset=${offset}`,
{ cache: 'no-store' }
);
if (!response.ok) {
const errText = await response.text();
console.error('[getDashboardSnapshotStore] Error:', response.status, errText.substring(0, 200));
return {};
}
const batch: Array<{ changed_at: string; new_data: any }> = await response.json();
rows.push(...batch);
if (batch.length < PAGE_SIZE) break;
}
const store: Record<string, DashboardSnapshotTabs> = {};
rows.forEach(row => {
const snapshotDate = normalizeSnapshotDate(row.new_data?.snapshot_date || row.changed_at);
const tabs = row.new_data?.tabs;
if (!snapshotDate || !tabs || typeof tabs !== 'object') return;
store[snapshotDate] = tabs as DashboardSnapshotTabs;
});
return store;
} catch (error) {
console.error('[getDashboardSnapshotStore] Exception:', error);
return {};
}
}
export async function ensureDashboardSnapshot(dateKey: string, tabs: DashboardSnapshotTabs): Promise<{ success: boolean; error?: string; created?: boolean }> {
try {
const existingRes = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history?select=id&product_id=eq.${encodeURIComponent(CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID)}&changed_at=gte.${encodeURIComponent(`${dateKey}T00:00:00.000Z`)}&changed_at=lt.${encodeURIComponent(nextUtcDateKey(dateKey))}&limit=1`,
{ cache: 'no-store' }
);
if (!existingRes.ok) {
const errText = await existingRes.text();
return { success: false, error: `Snapshot lookup failed: ${existingRes.status} ${errText.substring(0, 200)}` };
}
const existing = await existingRes.json();
if (Array.isArray(existing) && existing.length > 0) {
const existingId = existing[0]?.id;
const currentTabs = existing[0]?.new_data?.tabs ?? {};
const mergedTabs = {
...currentTabs,
...tabs,
};
if (JSON.stringify(currentTabs) === JSON.stringify(mergedTabs)) {
return { success: true, created: false };
}
if (!existingId) {
return { success: true, created: false };
}
const updateRes = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(existingId)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Prefer': 'return=minimal',
},
body: JSON.stringify({
new_data: {
snapshot_date: dateKey,
tabs: mergedTabs,
},
}),
}
);
if (!updateRes.ok) {
const errText = await updateRes.text();
return { success: false, error: `Snapshot update failed: ${updateRes.status} ${errText.substring(0, 200)}` };
}
return { success: true, created: false };
}
const payload = {
product_id: CONTROL_DASHBOARD_SNAPSHOT_PRODUCT_ID,
article_name: 'Control Dashboard Snapshot',
old_data: [],
new_data: {
snapshot_date: dateKey,
tabs,
},
changed_by: 'system-control-dashboard',
changed_at: `${dateKey}T00:00:00.000Z`,
};
const insertRes = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Prefer': 'return=minimal',
},
body: JSON.stringify(payload),
}
);
if (!insertRes.ok) {
const errText = await insertRes.text();
return { success: false, error: `Snapshot save failed: ${insertRes.status} ${errText.substring(0, 200)}` };
}
return { success: true, created: true };
} catch (error: any) {
console.error('[ensureDashboardSnapshot] Exception:', error);
return { success: false, error: error?.message || 'Network error' };
}
}
function normalizeSnapshotDate(value: string): string | null {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString().slice(0, 10);
}
function nextUtcDateKey(dateKey: string): string {
const date = new Date(`${dateKey}T00:00:00.000Z`);
date.setUTCDate(date.getUTCDate() + 1);
return date.toISOString();
}
export async function deleteHistoryEntry(id: string): Promise<boolean> {
try {
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(String(id))}`,
{ method: 'DELETE' }
);
return response.ok;
@@ -190,19 +527,17 @@ export async function deleteHistoryEntry(id: string, token?: string): Promise<bo
}
}
export async function resetAllPendingRows(token?: string): Promise<boolean> {
export async function resetAllPendingRows(): Promise<boolean> {
try {
const response = await fetch(
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'return=minimal'
'Prefer': 'return=minimal',
},
body: JSON.stringify({ status: 'synced' })
body: JSON.stringify({ status: 'edited' })
}
);
+4 -1
View File
@@ -1,5 +1,8 @@
export async function generateDescription(prompt: string, systemPrompt: string): Promise<string> {
const apiKey = import.meta.env.VITE_ANTHROPIC_API_KEY || localStorage.getItem('ANTHROPIC_API_KEY') || '';
const apiKey =
localStorage.getItem('ANTHROPIC_API_KEY') ||
import.meta.env.VITE_ANTHROPIC_API_KEY ||
'';
if (!apiKey) {
throw new Error('Anthropic API Key not found. Please set VITE_ANTHROPIC_API_KEY in .env or ANTHROPIC_API_KEY in localStorage.');
}
+207
View File
@@ -0,0 +1,207 @@
const BC_SYNC_PREVIEW_URL = '/api/bc-sync-preview';
const BC_SYNC_APPLY_URL = '/api/bc-sync-apply';
const BC_EXPORT_URL = '/api/bc-export';
export interface BCUpdateResult {
success: boolean;
error?: string;
}
export function isPreviewTokenMismatchError(error?: string): boolean {
return Boolean(error && error.toLowerCase().includes('preview token mismatch'));
}
export interface BCDownloadResult {
success: boolean;
error?: string;
filename?: string;
}
export interface BCFieldChange {
sourceLabel: string;
sourceIndex: number | null;
targetField: string;
before: any;
after: any;
changed: boolean;
}
export interface BCSyncPreviewSection {
type: 'items' | 'itemUnitsOfMeasure' | 'itemUnits40HC';
desired: Record<string, any>;
current: Record<string, any> | null;
changes: BCFieldChange[];
changedFields: string[];
writeConfigured: boolean;
writeMethod: string | null;
writeUrlTemplate: string | null;
writeBodyTemplate: string | null;
canApply: boolean;
supported?: boolean;
supportReason?: string | null;
}
export interface BCSyncPreviewResult {
success: boolean;
articleNo: string;
items: BCSyncPreviewSection;
itemUnitsOfMeasure: BCSyncPreviewSection;
itemUnits40HC: BCSyncPreviewSection;
hasChanges: boolean;
previewToken: string;
error?: string;
}
export interface BCSyncApplyResult {
success: boolean;
articleNo: string;
previewToken?: string;
preview?: BCSyncPreviewResult;
results?: {
items: { applied: boolean; reason?: string; url?: string };
itemUnitsOfMeasure: { applied: boolean; reason?: string; url?: string };
itemUnits40HC: { applied: boolean; reason?: string; url?: string };
};
error?: string;
warning?: string;
}
async function readResponsePayload(res: Response): Promise<{ data: any; rawText: string }> {
const rawText = await res.text();
if (!rawText.trim()) {
return { data: null, rawText };
}
try {
return { data: JSON.parse(rawText), rawText };
} catch {
return { data: rawText, rawText };
}
}
export async function updateCpnpNoInBC(articleNo: string, cpnpNo: string): Promise<BCUpdateResult> {
try {
const res = await fetch(BC_SYNC_APPLY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ articleNo, cpnpNo }),
});
const { data, rawText } = await readResponsePayload(res);
if (!res.ok || !data?.success) {
const error =
data?.error ||
(typeof data === 'string' && data.trim()) ||
rawText ||
`HTTP ${res.status}`;
return { success: false, error };
}
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}
export async function previewBusinessCentralSync(headers: string[], row: any[]): Promise<BCSyncPreviewResult> {
try {
const res = await fetch(BC_SYNC_PREVIEW_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ headers, row }),
});
const { data, rawText } = await readResponsePayload(res);
if (!res.ok || !data?.success) {
const error =
data?.error ||
(typeof data === 'string' && data.trim()) ||
rawText ||
`HTTP ${res.status}`;
return { success: false, error } as BCSyncPreviewResult;
}
return data as BCSyncPreviewResult;
} catch (err: any) {
return { success: false, error: err.message } as BCSyncPreviewResult;
}
}
export async function applyBusinessCentralSync(headers: string[], row: any[], previewToken?: string): Promise<BCSyncApplyResult> {
try {
const res = await fetch(BC_SYNC_APPLY_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ headers, row, previewToken }),
});
const { data, rawText } = await readResponsePayload(res);
if (!res.ok || !data?.success) {
const error =
data?.error ||
(typeof data === 'string' && data.trim()) ||
rawText ||
`HTTP ${res.status}`;
return { success: false, error } as BCSyncApplyResult;
}
return data as BCSyncApplyResult;
} catch (err: any) {
return { success: false, error: err.message } as BCSyncApplyResult;
}
}
function getFilenameFromDisposition(contentDisposition: string | null): string | null {
if (!contentDisposition) return null;
const utf8Match = contentDisposition.match(/filename\*\s*=\s*UTF-8''([^;]+)/i);
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1].trim().replace(/^"|"$/g, ''));
} catch {
return utf8Match[1].trim().replace(/^"|"$/g, '');
}
}
const filenameMatch = contentDisposition.match(/filename\s*=\s*([^;]+)/i);
if (filenameMatch?.[1]) {
return filenameMatch[1].trim().replace(/^"|"$/g, '');
}
return null;
}
export async function downloadBusinessCentralItemsExcel(): Promise<BCDownloadResult> {
try {
const res = await fetch(BC_EXPORT_URL, {
method: 'GET',
});
if (!res.ok) {
let error = `HTTP ${res.status}`;
try {
const { data, rawText } = await readResponsePayload(res);
error = data?.error || (typeof data === 'string' && data.trim()) || rawText || error;
} catch {
error = `HTTP ${res.status}`;
}
return { success: false, error };
}
const blob = await res.blob();
const filename =
getFilenameFromDisposition(res.headers.get('content-disposition')) ||
`BusinessCentral_Items_${new Date().toISOString().split('T')[0]}.xlsx`;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
return { success: true, filename };
} catch (err: any) {
return { success: false, error: err.message };
}
}
+174
View File
@@ -0,0 +1,174 @@
import { ExcelRow } from '../types';
export interface MappingFieldPreview {
sourceLabel: string;
sourceIndex: number | null;
targetField: string;
value: any;
}
export interface BusinessCentralMappingPreview {
articleNo: string;
itemsPayload: Record<string, any>;
itemUnitsOfMeasurePayload: Record<string, any>;
itemUnits40HCPayload: Record<string, any>;
itemsFields: MappingFieldPreview[];
itemUnitsFields: MappingFieldPreview[];
itemUnits40HCFields: MappingFieldPreview[];
}
function findHeaderIndex(headers: string[], patterns: string[]): number {
const normalized = headers.map(h => String(h || '').toLowerCase());
return normalized.findIndex(header =>
patterns.every(pattern => header.includes(pattern.toLowerCase()))
);
}
function findCategorizationCodeIndex(headers: string[]): number {
const normalized = headers.map(h => String(h || '').toLowerCase().trim());
const categorizationIdx = normalized.findIndex(header => header.replace(/[\s_-]+/g, '') === 'categorizationcode');
if (categorizationIdx >= 0) return categorizationIdx;
const typeIdx = normalized.findIndex(header => header === 'type');
if (typeIdx >= 0) return typeIdx;
return normalized.findIndex(header => header.includes('product') && header.includes('type'));
}
function formatDateForBc(value: any): string | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number' && value >= 25569 && value <= 60000) {
const excelEpoch = new Date(1899, 11, 30);
const date = new Date(excelEpoch.getTime() + value * 86400000);
return date.toISOString().split('T')[0];
}
const str = String(value).trim();
if (!str) return null;
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return str;
const parsed = new Date(str);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString().split('T')[0];
}
return str;
}
function getValue(row: ExcelRow, index: number | null): any {
if (index === null || index < 0) return null;
const value = row[index];
return value === undefined || value === '' ? null : value;
}
function toBcDecimal(value: any): number | null {
if (value === null || value === undefined || value === '') return null;
if (typeof value === 'number' && Number.isFinite(value)) return value;
const normalized = String(value).trim().replace(/\s+/g, '').replace(',', '.');
if (!normalized) return null;
const parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function makeFieldPreview(
sourceLabel: string,
sourceIndex: number | null,
targetField: string,
value: any
): MappingFieldPreview {
return { sourceLabel, sourceIndex, targetField, value };
}
export function buildBusinessCentralMappingPreview(headers: string[], row: ExcelRow): BusinessCentralMappingPreview {
const articleNoIdx = findHeaderIndex(headers, ['article', 'no']);
const articleDetailsEnIdx = findHeaderIndex(headers, ['article', 'details', 'english']);
const articleDetailsDeIdx = findHeaderIndex(headers, ['article', 'details', 'german']);
const launchDateIdx = findHeaderIndex(headers, ['launch']);
const readyToOrderDateIdx = findHeaderIndex(headers, ['ready']);
const moqIdx = findHeaderIndex(headers, ['moq']);
const shortDeIdx = findHeaderIndex(headers, ['short', 'description', 'german']);
const shortEnIdx = findHeaderIndex(headers, ['short', 'description', 'english']);
const cpnpIdx = findHeaderIndex(headers, ['cpnp']);
const categorizationCodeIdx = findCategorizationCodeIndex(headers);
const unitsOuterIdx = findHeaderIndex(headers, ['units', 'outer']);
const units40HqIdx = (() => {
const hqIdx = findHeaderIndex(headers, ['40', 'hq']);
if (hqIdx >= 0) return hqIdx;
return findHeaderIndex(headers, ['40', 'hc']);
})();
const outerWIdx = findHeaderIndex(headers, ['outer', 'w']);
const outerLIdx = findHeaderIndex(headers, ['outer', 'l']);
const outerHIdx = findHeaderIndex(headers, ['outer', 'h']);
const articleNo = String(getValue(row, articleNoIdx) ?? '');
const categorizationCode = getValue(row, categorizationCodeIdx);
const hasCategorizationCode = String(categorizationCode ?? '').trim() !== '';
const itemsPayload: Record<string, any> = {
no: getValue(row, articleNoIdx),
articleDetailsEnglish: getValue(row, articleDetailsEnIdx),
articleDetailsGerman: getValue(row, articleDetailsDeIdx),
launchDate: formatDateForBc(getValue(row, launchDateIdx)),
readyToOrderDate: formatDateForBc(getValue(row, readyToOrderDateIdx)),
minimumOrderQuantity: toBcDecimal(getValue(row, moqIdx)),
shortDescriptionInGerman: getValue(row, shortDeIdx),
shortDescriptionInEnglish: getValue(row, shortEnIdx),
cpnpNo: getValue(row, cpnpIdx),
};
if (hasCategorizationCode) {
itemsPayload.categorizationCode = String(categorizationCode).trim();
}
const itemUnitsOfMeasurePayload = {
itemNo: getValue(row, articleNoIdx),
code: 'OUTER',
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, unitsOuterIdx)),
width: toBcDecimal(getValue(row, outerWIdx)),
length: toBcDecimal(getValue(row, outerLIdx)),
height: toBcDecimal(getValue(row, outerHIdx)),
};
const itemUnits40HCPayload = {
itemNo: getValue(row, articleNoIdx),
code: '40HC',
qtyPerUnitOfMeasure: toBcDecimal(getValue(row, units40HqIdx)),
};
return {
articleNo,
itemsPayload,
itemUnitsOfMeasurePayload,
itemUnits40HCPayload,
itemsFields: [
makeFieldPreview('Article No.', articleNoIdx, 'no', itemsPayload.no),
makeFieldPreview('Article Details - English', articleDetailsEnIdx, 'articleDetailsEnglish', itemsPayload.articleDetailsEnglish),
makeFieldPreview('Article Details - German', articleDetailsDeIdx, 'articleDetailsGerman', itemsPayload.articleDetailsGerman),
makeFieldPreview('Launch Date', launchDateIdx, 'launchDate', itemsPayload.launchDate),
makeFieldPreview('Ready to Order Date', readyToOrderDateIdx, 'readyToOrderDate', itemsPayload.readyToOrderDate),
makeFieldPreview('MOQ', moqIdx, 'minimumOrderQuantity', itemsPayload.minimumOrderQuantity),
makeFieldPreview('Short Description - German', shortDeIdx, 'shortDescriptionInGerman', itemsPayload.shortDescriptionInGerman),
makeFieldPreview('Short Description - English', shortEnIdx, 'shortDescriptionInEnglish', itemsPayload.shortDescriptionInEnglish),
makeFieldPreview('CPNP', cpnpIdx, 'cpnpNo', itemsPayload.cpnpNo),
...(hasCategorizationCode
? [makeFieldPreview('CategorizationCode', categorizationCodeIdx, 'categorizationCode', itemsPayload.categorizationCode)]
: []),
],
itemUnitsFields: [
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnitsOfMeasurePayload.itemNo),
makeFieldPreview('Units Outer', unitsOuterIdx, 'qtyPerUnitOfMeasure', itemUnitsOfMeasurePayload.qtyPerUnitOfMeasure),
makeFieldPreview('Outer W (cm)', outerWIdx, 'width', itemUnitsOfMeasurePayload.width),
makeFieldPreview('Outer L (cm)', outerLIdx, 'length', itemUnitsOfMeasurePayload.length),
makeFieldPreview('Outer H (cm)', outerHIdx, 'height', itemUnitsOfMeasurePayload.height),
],
itemUnits40HCFields: [
makeFieldPreview('Article No.', articleNoIdx, 'itemNo', itemUnits40HCPayload.itemNo),
makeFieldPreview('Units 40FT HQ', units40HqIdx, 'qtyPerUnitOfMeasure', itemUnits40HCPayload.qtyPerUnitOfMeasure),
],
};
}
+16 -19
View File
@@ -1,25 +1,15 @@
export async function generateGemini(prompt: string, systemPrompt: string): Promise<string> {
// Try all possible ways to get the API key
const apiKey =
(window as any).GEMINI_API_KEY ||
localStorage.getItem('GEMINI_API_KEY') ||
import.meta.env.VITE_GEMINI_API_KEY ||
import.meta.env.GEMINI_API_KEY ||
(process.env as any).GEMINI_API_KEY ||
'';
// Use the fixed API key provided by the user
const apiKey = 'AIzaSyDizFnYYnsBRBCfTuT9xhIPBXY0jFQd7HA';
if (!apiKey) {
console.error('No Gemini API Key found in env or storage');
throw new Error('Gemini API Key not found. Please set VITE_GEMINI_API_KEY in .env or GEMINI_API_KEY in localStorage.');
}
// Model fallback list - updated for March 2026
// Model fallback list - using current stable and experimental versions
const modelOptions = [
'gemini-3.1-flash',
'gemini-3.1-pro',
'gemini-3.1-flash-lite',
'gemini-2.5-flash',
'gemini-2.5-flash-lite',
'gemini-2.5-pro',
'gemini-2.0-flash',
'gemini-1.5-flash'
'gemini-2.5-pro'
];
let lastError: any = null;
@@ -39,7 +29,7 @@ export async function generateGemini(prompt: string, systemPrompt: string): Prom
}]
}],
generationConfig: {
maxOutputTokens: 2048,
maxOutputTokens: 4096,
temperature: 0.2
}
})
@@ -61,5 +51,12 @@ export async function generateGemini(prompt: string, systemPrompt: string): Prom
}
}
throw new Error(lastError?.error?.message || lastError?.message || 'All Gemini models failed. Check console for details.');
let errorMessage = lastError?.error?.message || lastError?.message || 'All Gemini models failed.';
// Check if it's a quota error (429)
if (lastError?.error?.code === 429 || lastError?.status === 429 || errorMessage.toLowerCase().includes('quota') || errorMessage.toLowerCase().includes('429')) {
errorMessage = 'Gemini API quota exceeded. You may need to wait or switch to a paid billing plan in Google AI Studio.';
}
throw new Error(errorMessage + ' Check console for details.');
}
+105 -23
View File
@@ -9,32 +9,114 @@ export interface AppState {
asinColumnIndex: number | null;
}
// Canonical column indices (defaults)
// Aligned to the matrix layout introduced in July 2026, which added
// EmpCO_Compliant (9), Comments (10), Categorisation Code (11) and
// Ingredients (80), and removed PM Classification.
export const COLUMNS = {
ARTICLE_NO: 0,
ARTICLE_NAME: 2,
LINE: 7,
LICENSE: 8,
DETAILS_EN: 9,
DETAILS_DE: 10,
BARCODE: 28,
TARIFF_CODE: 58,
COUNTRY_ORIGIN: 60,
LONG_DE: 62,
LONG_EN: 63,
SHORT_DE: 64,
SHORT_EN: 65,
RECOMMENDED_AGE: 67,
CLASSIFICATION: 11,
ITEM_AVAILABLE: 14,
MOQ: 27,
UNITS_INNER: 31,
UNITS_OUTER: 32,
ASIN: 33,
INNER_W: 42,
INNER_L: 43,
INNER_H: 44,
OUTER_W: 47,
OUTER_L: 48,
OUTER_H: 49,
VERIFIED_DIMS: 100
DETAILS_EN: 12,
DETAILS_DE: 13,
BARCODE: 31,
TARIFF_CODE: 62,
COUNTRY_ORIGIN: 64,
LONG_DE: 65,
LONG_EN: 66,
SHORT_DE: 67,
SHORT_EN: 68,
RECOMMENDED_AGE: 71,
CLASSIFICATION: 14,
ITEM_AVAILABLE: 18,
MOQ: 30,
UNITS_INNER: 34,
UNITS_OUTER: 35,
ASIN: 78,
INNER_W: 45,
INNER_L: 46,
INNER_H: 47,
OUTER_W: 50,
OUTER_L: 51,
OUTER_H: 52,
VERIFIED_DIMS: 100,
VALIDATED_CHECK: 101,
VALIDATED_NOTE: 102,
CATEGORIZATION_CODE: 103,
PRODUCT_TYPE: 103,
ITEM_TO_LOGISTIC: 104,
ANNA_CHECK: 105,
ANNA_NOTE: 106,
CPNP_NO: 107
};
// Search patterns for dynamic detection
export const COLUMN_PATTERNS: Record<keyof typeof COLUMNS, string[]> = {
ARTICLE_NO: ['article', 'no'],
ARTICLE_NAME: ['article', 'name'],
LINE: ['line'],
LICENSE: ['license'],
DETAILS_EN: ['details', 'english'],
DETAILS_DE: ['details', 'german'],
BARCODE: ['article', 'barcode'],
TARIFF_CODE: ['tariff', 'code'],
COUNTRY_ORIGIN: ['country', 'origin'],
LONG_DE: ['long', 'description', 'german'],
LONG_EN: ['long', 'description', 'english'],
SHORT_DE: ['short', 'description', 'german'],
SHORT_EN: ['short', 'description', 'english'],
RECOMMENDED_AGE: ['recommended', 'age'],
CLASSIFICATION: ['classification'],
ITEM_AVAILABLE: ['item', 'available'],
MOQ: ['moq'],
UNITS_INNER: ['units', 'inner'],
UNITS_OUTER: ['units', 'outer'],
ASIN: ['asin'],
INNER_W: ['inner', 'w'],
INNER_L: ['inner', 'l'],
INNER_H: ['inner', 'h'],
OUTER_W: ['outer', 'w'],
OUTER_L: ['outer', 'l'],
OUTER_H: ['outer', 'h'],
VERIFIED_DIMS: ['verified', 'dims'],
VALIDATED_CHECK: ['validated', 'check'],
VALIDATED_NOTE: ['validated', 'note'],
CATEGORIZATION_CODE: ['categorization', 'code'],
PRODUCT_TYPE: ['product', 'type'],
ITEM_TO_LOGISTIC: ['item', 'logistic'],
ANNA_CHECK: ['anna', 'check'],
ANNA_NOTE: ['anna', 'note'],
CPNP_NO: ['cpnp'],
// Virtual/Extra columns stay hardcoded and are NOT auto-detected from headers
// to prevent internal data from being shifted or overwritten by Excel column shifts.
};
export function resolveColumnIndices(headers: string[]): typeof COLUMNS {
const resolved = { ...COLUMNS };
const h = headers.map(val => String(val || '').toLowerCase());
Object.entries(COLUMN_PATTERNS).forEach(([key, patterns]) => {
if (patterns.length === 0) return;
const idx = h.findIndex(headerText =>
patterns.every(p => headerText.includes(p.toLowerCase()))
);
if (idx >= 0) {
resolved[key as keyof typeof COLUMNS] = idx;
}
});
let categorizationIdx = h.findIndex(headerText => headerText.replace(/[\s_-]+/g, '') === 'categorizationcode');
if (categorizationIdx < 0) {
categorizationIdx = h.findIndex(headerText => headerText.trim() === 'type');
}
if (categorizationIdx >= 0) {
resolved.CATEGORIZATION_CODE = categorizationIdx;
resolved.PRODUCT_TYPE = categorizationIdx;
} else {
resolved.CATEGORIZATION_CODE = resolved.PRODUCT_TYPE;
}
return resolved;
}
+34 -3
View File
@@ -1,6 +1,37 @@
{
"rewrites": [
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" },
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" }
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "X-Content-Type-Options",
"value": "nosniff"
},
{
"key": "X-Frame-Options",
"value": "DENY"
},
{
"key": "X-XSS-Protection",
"value": "1; mode=block"
},
{
"key": "Referrer-Policy",
"value": "strict-origin-when-cross-origin"
},
{
"key": "Permissions-Policy",
"value": "camera=(), microphone=(), geolocation=()"
},
{
"key": "Strict-Transport-Security",
"value": "max-age=63072000; includeSubDomains; preload"
},
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' https://hwithddwaapyhnfwcesj.supabase.co https://api.dropboxapi.com https://www.dropbox.com https://generativelanguage.googleapis.com; font-src 'self'; frame-ancestors 'none';"
}
]
}
]
}
+179 -2
View File
@@ -2,11 +2,188 @@ import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
import { getBcConfig, getBCToken, findItem, patchItemCpnpNo, fetchAllItems, buildWorkbook } from './bc-runtime.js';
import * as XLSX from 'xlsx';
// @ts-ignore
import dropboxProxyHandler from './api/dropbox-proxy.js';
// @ts-ignore
import dropboxSyncHandler from './api/dropbox-sync.js';
// @ts-ignore
import backupHandler from './api/backup.js';
// @ts-ignore
import usersAdminHandler from './api/users-admin.js';
async function readJsonBody(req: any): Promise<any> {
return await new Promise((resolve, reject) => {
let raw = '';
req.on('data', (chunk: Buffer) => { raw += chunk.toString('utf8'); });
req.on('end', () => {
if (!raw.trim()) return resolve({});
try {
resolve(JSON.parse(raw));
} catch (err) {
reject(err);
}
});
req.on('error', reject);
});
}
function createLocalApiMiddleware(env: Record<string, string>, prodEnv: Record<string, string>) {
return async (req: any, res: any, next: any) => {
try {
const url = new URL(req.url || '/', 'http://localhost');
const config = getBcConfig(env);
// Populate process.env with loaded env variables for serverless handlers.
// The Dropbox proxy reads env at request time so preview/dev/production
// can share the same handler implementation.
Object.assign(process.env, prodEnv, env);
// Helper to adapt Node.js req/res to Vercel signature.
const adaptVercelHandler = async (handler: any) => {
(req as any).query = Object.fromEntries(url.searchParams.entries());
try {
(req as any).body = await readJsonBody(req);
} catch {
(req as any).body = {};
}
(res as any).status = (code: number) => {
res.statusCode = code;
return res;
};
(res as any).json = (data: any) => {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(data));
};
(res as any).send = (data: any) => {
if (data instanceof Buffer) {
res.end(data);
} else if (typeof data === 'object') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify(data));
} else {
res.end(data);
}
};
await handler(req, res);
};
if (url.pathname === '/api/dropbox-proxy') {
await adaptVercelHandler(dropboxProxyHandler);
return;
}
if (url.pathname === '/api/dropbox-sync') {
await adaptVercelHandler(dropboxSyncHandler);
return;
}
if (url.pathname === '/api/backup') {
await adaptVercelHandler(backupHandler);
return;
}
if (url.pathname === '/api/users-admin') {
await adaptVercelHandler(usersAdminHandler);
return;
}
if (url.pathname === '/api/bc-proxy') {
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
if (req.method !== 'POST') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
const body = await readJsonBody(req);
const { articleNo, cpnpNo } = body || {};
if (!articleNo || cpnpNo === undefined) {
res.statusCode = 400;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Missing articleNo or cpnpNo' }));
return;
}
const token = await getBCToken(config);
const item = await findItem(config, token, articleNo);
await patchItemCpnpNo(config, token, item, String(cpnpNo));
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, articleNo, cpnpNo }));
return;
}
if (url.pathname === '/api/bc-export') {
if (req.method === 'OPTIONS') {
res.statusCode = 204;
res.end();
return;
}
if (req.method !== 'GET') {
res.statusCode = 405;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ error: 'Method not allowed' }));
return;
}
const token = await getBCToken(config);
const items = await fetchAllItems(config, token);
if (url.searchParams.get('format') === 'json') {
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: true, count: items.length, items }));
return;
}
const workbook = buildWorkbook(items);
const buffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'buffer' });
const dateStr = new Date().toISOString().split('T')[0];
res.statusCode = 200;
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader('Content-Disposition', `attachment; filename="BusinessCentral_Items_${dateStr}.xlsx"`);
res.setHeader('Cache-Control', 'no-store');
res.end(buffer);
return;
}
} catch (err: any) {
const message = err?.message || String(err);
if ((req.url || '').startsWith('/api/bc-')) {
res.statusCode = 500;
res.setHeader('Content-Type', 'application/json');
res.end(JSON.stringify({ success: false, error: message }));
return;
}
console.error('[bc-local-api]', message);
}
next();
};
}
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
const prodEnv = loadEnv('production', '.', '');
const localApiMiddleware = createLocalApiMiddleware(env, prodEnv);
return {
plugins: [react(), tailwindcss()],
plugins: [
react(),
tailwindcss(),
{
name: 'bc-local-api',
configureServer(server) {
server.middlewares.use(localApiMiddleware);
},
configurePreviewServer(server) {
server.middlewares.use(localApiMiddleware);
},
},
],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
@@ -21,7 +198,7 @@ export default defineConfig(({mode}) => {
hmr: process.env.DISABLE_HMR !== 'true',
proxy: {
'/dropbox-file': {
target: 'https://dl.dropboxusercontent.com',
target: 'https://www.dropbox.com',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/dropbox-file/, ''),
},