Author SHA1 Message Date
Christian Vidal WolfandClaude Fable 5 ea4592b695 fix(pricing): widen CategorizationCode and EMCO Compliant columns
Default widths were too narrow for the header labels, causing them to
overlap and become unreadable (140->200 and 110->150).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 17:05:37 +02:00
Christian Vidal WolfandClaude Fable 5 b42dd32cda feat(pricing): source EMCO Compliant live from Business Central
EMCO Compliant is maintained manually in BC, so the pricing column now
fetches it from the new /api/bc-empco endpoint (items API, field
empCOCompliant) on every app load instead of reading the Dropbox matrix.
One fetch per page load, cached across tab switches; sorting uses the
live map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:45:45 +02:00
Christian Vidal WolfandClaude Fable 5 0c152a4742 feat(pricing): add EMCO Compliant column next to CategorizationCode
Read-only Yes/No badge sourced from the matrix EmpCO_Compliant column
(detected dynamically by header; shows '-' until the new matrix layout
is live in Dropbox). Sortable, pinnable and resizable like the rest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:36:13 +02:00
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
Christian Vidal Wolf fd5d7458b9 Add column filters to Missing Data view and include Classification in editable columns 2026-04-12 18:32:08 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 39869edcef feat: replace global EditPanel with focused 3-field panel in Missing Data
When editing from Missing Data view, only Classification, Launch Date
and Ready to Order fields are shown in a lightweight slide-in panel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:23:23 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 ce9e91bad1 feat: add Edit button to each row in Missing Data view
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:19:20 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 7eeccdaf8c feat: add Missing Data module as new sidebar entry
Replaces the tab approach with a dedicated 'Missing Data' module in the
sidebar. The module has two sub-tabs: 'Missing Classification' and
'Missing Launch Date'. Shows SKU, Name, Classification, Launch Date and
Ready to Order columns. Includes a local Excel serial date formatter so
dates that slipped through the App.tsx pre-processing are rendered
correctly instead of showing as raw numbers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:11:57 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 fdf878a4f8 feat: add Missing Class / Launch tab to Article Details
Adds a new 'Missing Class / Launch' tab in Article Details that surfaces
items where Classification is empty or Launch Date is blank/01-01-1900.
The tab shows Classification, Launch Date, and Ready to Order columns for
quick reference. Column indices for Launch Date and Ready to Order are
resolved dynamically from the Excel headers.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:02:52 +02:00
Christian Vidal Wolf d1f1c9a467 feat: restrict History and Pending Validation tabs to master user 2026-04-11 15:00:58 +02:00
Christian Vidal Wolf 7ecd1c45d0 UI: Add group verification for Dimensions inconsistencies and persist to DB 2026-04-11 11:48:56 +02:00
Christian Vidal Wolf 0e797e06bb feat: add voice-to-text input using Web Speech API 2026-04-11 11:34:57 +02:00
Christian Vidal Wolf 5104dfd59d chore: install buzz package for Python-style type checking 2026-04-11 11:31:58 +02:00
Christian Vidal Wolf 385ea2972c feat: increase default page size from 25 to 100 in ProductDescriptions and ArticleDetails 2026-04-11 11:18:58 +02:00
Christian Vidal Wolf aebf46493b UI: Increase column widths for description headers to prevent cutoff 2026-04-11 11:15:41 +02:00
Christian Vidal Wolf dcdf50593d Comprehensive audit: Replace backend JWT dependency universally with anon key and strict cache rules 2026-04-10 19:56:42 +02:00
Christian Vidal Wolf d9cc1f4506 Fix silent failure on getAllSyncedRows by forcing anon key 2026-04-10 19:52:30 +02:00
Christian Vidal Wolf d3c90120f4 Force anon key on getHistory and expose errors visually 2026-04-10 19:48:33 +02:00
Christian Vidal Wolf 1c72eae60a Disable fetch cache to sync history across computers 2026-04-10 19:45:05 +02:00
Christian Vidal Wolf 61daf8083f fix: preserve 0 values in EditPanel form inputs 2026-04-10 12:55:47 +02:00
Christian Vidal Wolf 15913706fa fix: show 0 values in DimensionsView instead of empty 2026-04-10 12:53:47 +02:00
Christian Vidal Wolf 20c4cb1a40 fix: preserve 0 values in badge check 2026-04-10 12:50:15 +02:00
Christian Vidal Wolf 95eb6ab91b feat: add Edit button to history items to modify changes 2026-04-10 12:46:26 +02:00
Christian Vidal Wolf be584600fc feat: add clear X button to all search inputs 2026-04-10 12:39:31 +02:00
Christian Vidal Wolf dbcd6d622f Implement smart merge of synced data to preserve external updates 2026-04-10 12:31:57 +02:00
Christian Vidal Wolf 8849303f58 Fix data overwrite on reload by ignoring duplicates in dropbox-sync 2026-04-10 12:28:48 +02:00
Christian Vidal Wolf 7a5e1a1b26 Fix token definition oversight 2026-04-10 12:25:58 +02:00
Christian Vidal Wolf bdd15cbd33 Show specific critical error message 2026-04-10 12:16:06 +02:00
Christian Vidal Wolf 3c821b83a0 Gracefully handle JWT expired and warn user to re-login 2026-04-10 12:09:49 +02:00
Christian Vidal Wolf db91aeb842 Improve Supabase error reporting for diagnostic purposes 2026-04-10 12:06:16 +02:00
Christian Vidal Wolf ee73b85100 Fix save changes looping issue and authorize all Supabase API calls 2026-04-10 12:03:12 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 a92e136372 fix: update Dropbox file path to correct location
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:51:41 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 1bff4f11a6 fix: use refresh token flow for permanent Dropbox access
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:24:28 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 8e5cc6745a fix: use DROPBOX_ACCESS_TOKEN directly in proxy (skip refresh flow)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 11:20:16 +02:00
Christian Vidal Wolf 1aaf454ba5 fix: use raw fetch for Dropbox API instead of SDK, add better error logging 2026-04-10 11:01:55 +02:00
Christian Vidal Wolf 90e60017da feat: sync Excel data to Supabase on load - detect new Dropbox files automatically 2026-04-10 10:54:50 +02:00
Christian Vidal Wolf 077e78513c fix: use POST with upsert instead of PATCH to handle new products 2026-04-10 10:48:05 +02:00
Christian Vidal Wolf 0139e1de20 fix: add debug logs to handleSaveAll to diagnose save issue 2026-04-10 10:45:11 +02:00
Christian Vidal Wolf bb1af0dbbd fix: correctly track hasUnsavedChanges when only 1 pending change remains 2026-04-10 10:37:40 +02:00
Christian Vidal Wolf 4e6563d38d Move filter icon next to header text in all views 2026-04-10 10:05:59 +02:00
Christian Vidal Wolf 19df2d04e4 Move filter icon closer to header text in ArticleDetails 2026-04-10 10:02:26 +02:00
Christian Vidal Wolf 23f4b508c8 Delete history entry after revert 2026-04-10 09:47:35 +02:00
Christian Vidal Wolf 0cf4f59937 Warn when reverting to original state (no actual changes) 2026-04-10 09:42:04 +02:00
Christian Vidal Wolf 7bf37eb017 style: adjust ASIN column width to fit 10 characters in ProductDescriptions 2026-04-10 09:28:49 +02:00
Christian Vidal Wolf 161e77a76b Set ASIN column width to 90px 2026-04-10 09:25:34 +02:00
Christian Vidal Wolf 0c75e91c4e Reduce ASIN column width to 120px 2026-04-10 09:23:51 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 64a9c40ec3 fix: move session check after all hooks to fix blank page on login
Early conditional return before useEffect/useMemo violated React Rules
of Hooks, causing error #300 (rendered more hooks than previous render)
after login.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 08:32:03 +02:00
Christian Vidal Wolf 1339a34bb0 Fix React hooks order - all useState at top 2026-04-10 08:27:36 +02:00
Christian Vidal Wolf 935f3f02ec Add debug logging for session changes 2026-04-10 08:24:58 +02:00
Christian Vidal Wolf 1ef865250a Fix login blank page issue 2026-04-10 08:20:44 +02:00
Christian Vidal Wolf 22dfa30708 Dynamically detect ASIN column from Excel headers 2026-04-09 16:33:05 +02:00
Christian Vidal Wolf 4a6440f58a Detect ASIN column index dynamically from headers 2026-04-09 16:29:54 +02:00
Christian Vidal Wolf 9c73efa8cf Fix ASIN column index (column 33, after Item to root units) 2026-04-09 16:23:25 +02:00
Christian Vidal Wolf db69ada011 Add ASIN column to Product Descriptions 2026-04-09 16:20:44 +02:00
Christian Vidal Wolf 80fff047ab fix: ensure Gemini translates all-caps phrases and maintains casing 2026-04-09 11:05:00 +02:00
Christian Vidal Wolf e773c6c9c4 fix: tighten Gemini prompt to avoid conversational filler 2026-04-09 11:00:12 +02:00
Christian Vidal Wolf 3f3c7eb3b2 feat: add change history and fix description editing focus bug 2026-04-09 10:57:39 +02:00
Christian Vidal Wolf cf851e97c4 Fix: prevent keyboard events from closing edit panel 2026-04-09 10:41:05 +02:00
Christian Vidal Wolf 0b100398e7 Fix: focus textarea after AI generation for manual editing 2026-04-09 10:20:59 +02:00
Christian Vidal Wolf f125572572 Allow any email to register and fix logout blank page bug 2026-04-09 09:58:01 +02:00
Christian Vidal Wolf ec541f1238 feat: simplify Product Descriptions tabs 2026-04-09 09:44:24 +02:00
Christian Vidal Wolf 899016f0a0 feat: add Pending Validation tab showing all pending changes 2026-04-09 09:24:17 +02:00
Christian Vidal Wolf a5c8076e18 feat: always show undo button in DimensionsView 2026-04-09 09:17:11 +02:00
Christian Vidal Wolf 2ea5bf80aa feat: add undo button in DimensionsView for pending changes 2026-04-09 09:13:18 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 1b0b35d3bf feat: show Save button always, disabled when no pending changes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 09:03:19 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 a7d8dad36e feat: revert individual pending changes from TopBar dropdown
- pendingRows now stores originalData + newData per article so changes
  can be individually reverted to their pre-edit state
- TopBar Save button becomes a split button: left saves all, right
  opens a dropdown listing every pending change with article no + name
- Each row in the dropdown has a Revert button that restores the
  original data locally and removes it from the pending queue
- Dropdown closes automatically when all changes are saved/reverted
  or when clicking outside

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:58:31 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 acc1d06269 feat: manual Save All button in TopBar with pending change tracking
Changes are now queued locally (yellow highlight) and only persisted to
Supabase when the user clicks the Save button in the top-right corner.
The button shows the count of pending changes and a spinner while saving.
EditPanel closes immediately after queuing — no Supabase call per edit.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:45:22 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 c544b9b709 feat: persistent multi-step undo system in TopBar
fix: save all changes to Supabase with reliable upsert

- Replace broken PATCH→POST fallback with single atomic upsert (POST +
  Prefer: resolution=merge-duplicates). The old PATCH returned 200 OK
  with empty body for new articles, causing silent data loss on every
  first save per article.
- Fix getAllSyncedRows pagination: add explicit limit=10000 and Range
  header to bypass Supabase's default 1000-row cap.
- Add fetchWithRetry helper (2 retries on 5xx/network errors).
- handleSaveRow now returns Promise<boolean> and closes EditPanel only
  after a confirmed successful save.
- Add 'error' save status: failed rows turn red (border-l-red-500)
  across ProductDescriptions, ArticleDetails, and PricingView.
- EditPanel shows saving spinner, disables buttons while saving, and
  displays inline error message with "Retry Save" on failure.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 08:22:58 +02:00
Christian Vidal Wolf da4673f7c4 feat: resizable columns in ProductDescriptions and ArticleDetails 2026-04-08 20:21:36 +02:00
Christian Vidal Wolf 968547c8c7 feat: persistent multi-step undo system in TopBar 2026-04-08 20:11:00 +02:00
Christian Vidal Wolf 72e653e353 feat: persistent validation visibility in DimensionsView and selection fixes 2026-04-08 20:05:24 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 e24e3e5067 debug: add console logs to trace filter state + stopPropagation on popover
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 19:59:11 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 d3675bba0b fix: resolve column filter checkboxes not responding to clicks
Replace label+hidden-input pattern in ColumnFilterPopover with direct
onClick on div — browsers don't reliably fire onChange for display:none
inputs activated via label click. Also adds missing columnFilters to
useMemo deps in ProductDescriptions and ArticleDetails, and restores
rowStatuses prop wiring in ProductDescriptions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 19:49:48 +02:00
Christian Vidal WolfandQwen-Coder 71a4897f28 fix: remove rowStatuses prop from ProductDescriptions to fix app crash
The rowStatuses prop was merged from feature branch but is incompatible
with main. Removed it and restored original tbody structure while keeping
the Present/Missing filter fix.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-08 19:32:28 +02:00
Christian Vidal Wolf 0d47f5be33 feat: implement persistence system with visual highlighting and auto-reset on export 2026-04-08 19:30:41 +02:00
Christian Vidal WolfandQwen-Coder 85451a6b31 fix: add parentheses for correct boolean operator precedence in column filter
The missing/present filter was not working due to operator precedence.
Added explicit parentheses to ensure correct evaluation.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-08 19:27:29 +02:00
73 changed files with 14338 additions and 950 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. # Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY" 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. # APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL. # AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints. # Used for self-referential links, OAuth callbacks, and API endpoints.
+13
View File
@@ -8,3 +8,16 @@ coverage/
!.env.example !.env.example
.vercel .vercel
.env*.local .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
}
}
}
+14
View File
@@ -0,0 +1,14 @@
{
"permissions": {
"allow": [
"Bash(npm run *)",
"Bash(git checkout *)",
"Bash(git merge *)",
"Bash(npx *)",
"Bash(git commit *)",
"Bash(git show *)",
"Bash(diff *)"
]
},
"$version": 3
}
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm run *)"
]
}
}
+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 @@
{}
+21 -76
View File
@@ -1,5 +1,16 @@
# AGENTS.md - Developer Guidelines for Craze-Data-check # 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 ## Build, Lint, and Test Commands
### Development ### Development
@@ -15,7 +26,7 @@ npm run clean # Remove dist folder
npm run lint # TypeScript type check only (tsc --noEmit) npm run lint # TypeScript type check only (tsc --noEmit)
``` ```
Note: This project does not have a separate test framework configured. To add tests, consider installing Vitest or Jest. Note: No test framework configured. To add tests, install Vitest or Jest.
--- ---
@@ -27,10 +38,8 @@ Note: This project does not have a separate test framework configured. To add te
- Keep components focused and modular - Keep components focused and modular
- Use meaningful variable and function names - Use meaningful variable and function names
### Imports ### Imports (order top to bottom)
1. React (`react`)
**Order (top to bottom):**
1. React imports (`react`)
2. External libraries (`lucide-react`, `xlsx`, etc.) 2. External libraries (`lucide-react`, `xlsx`, etc.)
3. Internal components (`./components/...`) 3. Internal components (`./components/...`)
4. Internal lib/utils (`./lib/...`) 4. Internal lib/utils (`./lib/...`)
@@ -44,19 +53,16 @@ import { cn } from '../lib/utils';
``` ```
### TypeScript Conventions ### TypeScript Conventions
- Use explicit types for props and function parameters - Use explicit types for props and function parameters
- Use `any` sparingly; prefer union types or interfaces - Use `any` sparingly; prefer union types or interfaces
- Define column indices in a centralized `COLUMNS` object (see `src/types.ts`) - Define column indices in a centralized `COLUMNS` object (see `src/types.ts`)
```typescript ```typescript
// Good
interface ProductDescriptionsProps { interface ProductDescriptionsProps {
data: ExcelRow[]; data: ExcelRow[];
onEdit: (index: number) => void; onEdit: (index: number) => void;
} }
// Good - centralized constants
export const COLUMNS = { export const COLUMNS = {
ARTICLE_NO: 0, ARTICLE_NO: 0,
ARTICLE_NAME: 2, ARTICLE_NAME: 2,
@@ -76,119 +82,58 @@ export const COLUMNS = {
| Types | PascalCase | `TabType`, `SortDirection` | | Types | PascalCase | `TabType`, `SortDirection` |
### React Patterns ### React Patterns
- Destructure props in function signature - Destructure props in function signature
- Use `useMemo` for expensive computations - Use `useMemo` for expensive computations
- Use `useCallback` for event handlers passed to child components - Use `useCallback` for event handlers passed to child components
- Keep `useState` calls at the top of component - Keep `useState` calls at the top of component
```typescript
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState('');
const filteredData = useMemo(() => {
// expensive computation
}, [data, activeTab, search]);
}
```
### Error Handling ### Error Handling
- Use TypeScript's type system for runtime safety - Use TypeScript's type system for runtime safety
- Use optional chaining (`?.`) and nullish coalescing (`??`) - Use optional chaining (`?.`) and nullish coalescing (`??`)
- Validate file uploads with proper type checks - Validate file uploads with proper type checks
```typescript
const file = e.target.files?.[0];
if (!file) return;
// Validate Excel data
if (data.length > 0) {
const rawHeaders = data[0];
const rawRows = data.slice(1);
}
```
### UI/Styling ### UI/Styling
- Use Tailwind CSS for all styling - Use Tailwind CSS for all styling
- Use `cn()` utility from `lib/utils` for conditional classes - Use `cn()` utility from `lib/utils` for conditional classes
- Follow existing color scheme (slate, blue, green, red for status) - Follow existing color scheme (slate, blue, green, red for status)
- Use `lucide-react` for icons - Use `lucide-react` for icons
- Keep responsive design in mind
```typescript ```typescript
import { cn } from '../lib/utils'; <button className={cn("px-4 py-2 rounded-md", isActive ? "bg-blue-600" : "bg-slate-800")}>
<button
className={cn(
"px-4 py-2 rounded-md text-sm font-medium",
isActive
? "bg-blue-600 text-white"
: "bg-slate-800 text-slate-400"
)}
>
``` ```
### File Organization ### File Organization
``` ```
src/ src/
├── components/ # React components ├── components/ # React components
│ ├── Sidebar.tsx ├── lib/ # Utilities (utils.ts, auth.ts, supabase.ts)
│ ├── MatrixView.tsx ├── services/ # External API integrations (gemini.ts, anthropic.ts)
│ └── ...
├── lib/ # Utilities and helpers
│ ├── utils.ts # cn(), helpers
│ ├── auth.ts # Authentication
│ └── supabase.ts # Database operations
├── services/ # External API integrations
│ ├── gemini.ts
│ └── anthropic.ts
├── types.ts # TypeScript types and constants ├── types.ts # TypeScript types and constants
├── App.tsx # Main application ├── App.tsx # Main application
└── main.tsx # Entry point └── main.tsx # Entry point
``` ```
### Data Processing ### Data Processing
- Handle both string and number types when processing Excel data
- When processing Excel data, handle both string and number types
- Use centralized column index constants - Use centralized column index constants
- Format numbers consistently (2 decimal places for prices/weights) - Format numbers consistently (2 decimal places for prices/weights)
- Handle Excel date serial numbers properly (convert to readable dates) - Handle Excel date serial numbers properly
```typescript ```typescript
// Handle date columns from Excel // Handle date columns from Excel
if (header.includes('date') || header.includes('launch')) {
if (typeof val === 'number' && val >= 25569 && val <= 60000) { if (typeof val === 'number' && val >= 25569 && val <= 60000) {
const excelEpoch = new Date(1899, 11, 30); const excelEpoch = new Date(1899, 11, 30);
const date = new Date(excelEpoch.getTime() + val * 86400000); return new Date(excelEpoch.getTime() + val * 86400000).toLocaleDateString('en-GB');
return date.toLocaleDateString('en-GB');
}
} }
``` ```
### Git Workflow ### Git Workflow
- Make small, focused commits - Make small, focused commits
- Write clear commit messages describing what changed - Write clear commit messages describing what changed
- Push to main to trigger Vercel deployment automatically - Push to main to trigger Vercel deployment automatically
### Running Single Components
When testing or developing specific features:
```bash
npm run dev # Start dev server - access at http://localhost:3000
```
### Environment Variables ### Environment Variables
- Use `.env` file for local development - Use `.env` file for local development
- Never commit secrets - use Vercel dashboard for production env vars - Never commit secrets - use Vercel dashboard for production env vars
Required environment variables (production): Required: `VITE_SUPABASE_URL`, `VITE_SUPABASE_ANON_KEY`, `VITE_GEMINI_API_KEY`, `VITE_ANTHROPIC_API_KEY`
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_ANON_KEY`
- `VITE_GEMINI_API_KEY`
- `VITE_ANTHROPIC_API_KEY`
+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: 1. Install dependencies:
`npm install` `npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key 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` `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 });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { applyCors, isAllowedOrigin } from './_cors.js';
import { getBcConfig, getBCToken, getItemsUrl } 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 itemsUrl = getItemsUrl(config);
// Note: $select suppresses @odata.nextLink on this BC API, so page manually with $skip.
const TOP = 1000;
const values = {};
for (let skip = 0; ; skip += TOP) {
const response = await fetch(`${itemsUrl}?$select=no,empCOCompliant&$top=${TOP}&$skip=${skip}`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
});
if (!response.ok) {
const txt = await response.text();
throw new Error(`BC GET items failed (${response.status}): ${txt}`);
}
const json = await response.json();
const page = json.value || [];
for (const item of page) {
if (item.no !== undefined && item.no !== null) {
values[String(item.no)] = Boolean(item.empCOCompliant);
}
}
if (page.length < TOP) break;
}
res.setHeader('Cache-Control', 's-maxage=300, stale-while-revalidate=600');
return res.json({ success: true, count: Object.keys(values).length, values });
} catch (err) {
console.error('[bc-empco] 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'
});
}
+125 -8
View File
@@ -1,16 +1,132 @@
// Vercel serverless function — proxies the Dropbox file server-side (no CORS) 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: refreshToken,
client_id: appKey,
client_secret: appSecret,
})
});
const data = await response.json();
if (!data.access_token) {
throw new Error('Failed to get access token: ' + JSON.stringify(data));
}
return data.access_token;
}
export default async function handler(req, res) { export default async function handler(req, res) {
const fileUrl = setCors(req, res);
'https://dl.dropboxusercontent.com/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx' +
'?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1'; 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') {
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 { try {
const upstream = await fetch(fileUrl, { const accessToken = await getAccessToken();
headers: { 'User-Agent': 'Mozilla/5.0' }, 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({ 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) { if (!upstream.ok) {
return res.status(upstream.status).send(`Dropbox error: ${upstream.status}`); const errText = await upstream.text();
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(); const buffer = await upstream.arrayBuffer();
@@ -18,6 +134,7 @@ export default async function handler(req, res) {
res.setHeader('Cache-Control', 'no-store'); res.setHeader('Cache-Control', 'no-store');
res.send(Buffer.from(buffer)); res.send(Buffer.from(buffer));
} catch (err) { } catch (err) {
res.status(500).send('Proxy error: ' + err.message); console.error('Dropbox proxy error:', err);
res.status(500).send('Proxy error: ' + (err?.message || String(err)));
} }
} }
+106
View File
@@ -0,0 +1,106 @@
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_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 } = 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() === '') 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: 'excel',
updated_at: new Date().toISOString()
});
}
}
console.log('Upserting/updating', productsToUpsert.length, 'products in Supabase...');
// 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 in chunk:', error);
return res.status(500).json({ error: error.message, detail: 'Failed to upsert products chunk' });
}
}
return res.json({
success: true,
syncedCount: productsToUpsert.length
});
} catch (err) {
console.error('Handler error:', err);
res.status(500).json({ error: err.message });
}
}
+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`
+247
View File
@@ -9,10 +9,12 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",
"@supabase/supabase-js": "^2.103.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"dropbox": "^10.34.0",
"express": "^4.21.2", "express": "^4.21.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
@@ -1175,6 +1177,92 @@
"win32" "win32"
] ]
}, },
"node_modules/@supabase/auth-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz",
"integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz",
"integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/phoenix": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz",
"integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==",
"license": "MIT"
},
"node_modules/@supabase/postgrest-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz",
"integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==",
"license": "MIT",
"dependencies": {
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz",
"integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==",
"license": "MIT",
"dependencies": {
"@supabase/phoenix": "^0.4.0",
"@types/ws": "^8.18.1",
"tslib": "2.8.1",
"ws": "^8.18.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz",
"integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==",
"license": "MIT",
"dependencies": {
"iceberg-js": "^0.8.1",
"tslib": "2.8.1"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.103.0",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz",
"integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.103.0",
"@supabase/functions-js": "2.103.0",
"@supabase/postgrest-js": "2.103.0",
"@supabase/realtime-js": "2.103.0",
"@supabase/storage-js": "2.103.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -1549,6 +1637,17 @@
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
}, },
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/qs": { "node_modules/@types/qs": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
@@ -1602,6 +1701,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
@@ -1659,6 +1767,12 @@
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.27", "version": "10.4.27",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz",
@@ -1905,6 +2019,18 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -1985,6 +2111,15 @@
} }
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -2025,6 +2160,41 @@
"url": "https://dotenvx.com" "url": "https://dotenvx.com"
} }
}, },
"node_modules/dropbox": {
"version": "10.34.0",
"resolved": "https://registry.npmjs.org/dropbox/-/dropbox-10.34.0.tgz",
"integrity": "sha512-5jb5/XzU0fSnq36/hEpwT5/QIep7MgqKuxghEG44xCu7HruOAjPdOb3x0geXv5O/hd0nHpQpWO+r5MjYTpMvJg==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1"
},
"engines": {
"node": ">=0.10.3"
},
"peerDependencies": {
"@types/node-fetch": "^2.5.7"
}
},
"node_modules/dropbox/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -2112,6 +2282,21 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.27.4", "version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
@@ -2318,6 +2503,22 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/formdata-polyfill": { "node_modules/formdata-polyfill": {
"version": "4.0.10", "version": "4.0.10",
"resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
@@ -2564,6 +2765,21 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2609,6 +2825,15 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/iceberg-js": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/iconv-lite": { "node_modules/iconv-lite": {
"version": "0.4.24", "version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
@@ -3709,6 +3934,12 @@
"node": ">=0.6" "node": ">=0.6"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -4367,6 +4598,22 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wmf": { "node_modules/wmf": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
+2
View File
@@ -12,10 +12,12 @@
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",
"@supabase/supabase-js": "^2.103.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"dropbox": "^10.34.0",
"express": "^4.21.2", "express": "^4.21.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
+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
+981 -86
View File
File diff suppressed because it is too large Load Diff
+125 -29
View File
@@ -1,26 +1,54 @@
import React, { useState, useMemo } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X as XIcon } from 'lucide-react'; import { useColumns } from '../contexts/ColumnsContext';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover'; import { ColumnFilterPopover } from './ColumnFilterPopover';
import { SyncStatusPill } from './SyncStatusPill';
import { type DashboardDrilldownRequest } from '../lib/controlDashboard';
interface ArticleDetailsProps { interface ArticleDetailsProps {
data: ExcelRow[]; data: ExcelRow[];
onEdit: (index: number) => void; onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
dashboardDrilldown?: DashboardDrilldownRequest | null;
onDashboardDrilldownApplied?: () => void;
} }
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock'; type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) { export function ArticleDetails({ data, onEdit, rowStatuses, dashboardDrilldown, onDashboardDrilldownApplied }: ArticleDetailsProps) {
const COLUMNS = useColumns();
const [activeTab, setActiveTab] = useState<TabType>('all'); const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState(''); const [lineFilter, setLineFilter] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null); const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false); const [sortDesc, setSortDesc] = useState(false);
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(100);
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({}); const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null); 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,
[COLUMNS.CLASSIFICATION]: 80,
[COLUMNS.ITEM_AVAILABLE]: 80,
[COLUMNS.DETAILS_DE]: 150,
[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 lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
@@ -70,7 +98,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
} }
return result; return result;
}, [data, activeTab, search, lineFilter, sortCol, sortDesc]); }, [data, activeTab, search, lineFilter, columnFilters, sortCol, sortDesc]);
const paginatedData = useMemo(() => { const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
@@ -88,6 +116,25 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
} }
}; };
const handleResize = (colIndex: number, e: React.MouseEvent) => {
e.preventDefault();
const startX = e.pageX;
const startWidth = columnWidths[colIndex] || 100;
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 getUniqueValues = (col: number) => {
const values = data.map(r => String(r[col] || '')); const values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort(); return Array.from(new Set(values)).sort();
@@ -131,8 +178,18 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
]; ];
return ( 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"}>
<div className="flex flex-wrap gap-2 mb-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 items-center justify-between mb-6">
<div className="flex flex-wrap gap-2">
{tabs.map(tab => ( {tabs.map(tab => (
<button <button
key={tab.id} key={tab.id}
@@ -148,6 +205,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
</button> </button>
))} ))}
</div> </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"> <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"> <div className="flex-1 min-w-[200px] relative">
@@ -157,8 +222,16 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
placeholder="Search SKU or Name..." placeholder="Search SKU or Name..."
value={search} value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 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" 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>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && ( {(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button <button
@@ -168,7 +241,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
}} }}
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20" className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
> >
<XIcon className="w-4 h-4" /> <X className="w-4 h-4" />
Clear All Column Filters Clear All Column Filters
</button> </button>
)} )}
@@ -184,7 +257,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col"> <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"> <div className="overflow-x-auto flex-1">
<table className="w-full text-left text-xs"> <table className="w-full text-left text-xs" style={{ tableLayout: 'fixed' }}>
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10"> <thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
<tr> <tr>
{[ {[
@@ -197,31 +270,40 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
].map(({ col, label }) => ( ].map(({ col, label }) => (
<th <th
key={col} key={col}
className="px-3 py-3 font-medium transition-colors select-none group relative" className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
> >
<div className="flex items-center justify-between gap-1"> <div className="flex items-center overflow-hidden">
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}> <span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{label} {label}
{sortCol === col && ( {sortCol === col && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" /> sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)} )}
</div> </span>
<button <button
id={`details-filter-trigger-${col}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setOpenFilterCol(openFilterCol === col ? null : col); setOpenFilterCol(openFilterCol === col ? null : col);
}} }}
className={cn( className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors", "p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100" (columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
)} )}
> >
<Filter className="w-3.5 h-3.5" /> <Filter className="w-3 h-3" />
</button> </button>
</div> </div>
{/* Resizer handle */}
<div
onMouseDown={(e) => handleResize(col, e)}
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
/>
{openFilterCol === col && ( {openFilterCol === col && (
<ColumnFilterPopover <ColumnFilterPopover
triggerId={`details-filter-trigger-${col}`}
uniqueValues={getUniqueValues(col)} uniqueValues={getUniqueValues(col)}
selectedValues={columnFilters[col] || []} selectedValues={columnFilters[col] || []}
onToggle={(val) => toggleColumnFilter(col, val)} onToggle={(val) => toggleColumnFilter(col, val)}
@@ -239,17 +321,30 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
)} )}
</th> </th>
))} ))}
<th className="px-3 py-3 font-medium text-right">Edit</th> <th className="px-3 py-3 font-medium text-right" style={{ width: 80 }}>Edit</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/30"> <tbody className="divide-y divide-slate-700/30">
{paginatedData.map(({ row, index }) => ( {paginatedData.map(({ row, index }) => {
<tr key={index} className="hover:bg-slate-700/20 transition-colors"> const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
<td className="px-3 py-2 font-mono text-indigo-400">{row[COLUMNS.ARTICLE_NO]}</td> return (
<td className="px-3 py-2 font-medium text-slate-200 max-w-[150px] truncate" title={row[COLUMNS.ARTICLE_NAME]}> <tr
key={index}
className={cn(
"hover:bg-slate-700/20 transition-colors",
""
)}
>
<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]} {row[COLUMNS.ARTICLE_NAME]}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
<span className={cn( <span className={cn(
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border", "px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50" String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50"
@@ -257,7 +352,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
{row[COLUMNS.CLASSIFICATION] || '—'} {row[COLUMNS.CLASSIFICATION] || '—'}
</span> </span>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
<span className={cn( <span className={cn(
"font-mono font-bold", "font-mono font-bold",
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400" Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
@@ -265,14 +360,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
{row[COLUMNS.ITEM_AVAILABLE] || 0} {row[COLUMNS.ITEM_AVAILABLE] || 0}
</span> </span>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
{row[COLUMNS.DETAILS_DE] ? ( {row[COLUMNS.DETAILS_DE] ? (
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div> <div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
) : getBadge(null)} ) : getBadge(null)}
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
{row[COLUMNS.DETAILS_EN] ? ( {row[COLUMNS.DETAILS_EN] ? (
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div> <div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
) : getBadge(null)} ) : getBadge(null)}
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
@@ -284,7 +379,8 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
</button> </button>
</td> </td>
</tr> </tr>
))} );
})}
{paginatedData.length === 0 && ( {paginatedData.length === 0 && (
<tr> <tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500"> <td colSpan={7} className="px-4 py-8 text-center text-slate-500">
+353 -24
View File
@@ -1,7 +1,16 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { Search, Check, X } from 'lucide-react'; import { createPortal } from 'react-dom';
import { Search, Check, X, Filter } from 'lucide-react';
import { cn } from '../lib/utils'; 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 { interface ColumnFilterPopoverProps {
uniqueValues: string[]; uniqueValues: string[];
selectedValues: string[]; selectedValues: string[];
@@ -11,6 +20,8 @@ interface ColumnFilterPopoverProps {
onClose: () => void; onClose: () => void;
title?: string; title?: string;
className?: string; className?: string;
zIndex?: number;
triggerId?: string;
} }
export function ColumnFilterPopover({ export function ColumnFilterPopover({
@@ -21,25 +32,224 @@ export function ColumnFilterPopover({
onClear, onClear,
onClose, onClose,
title, title,
className className,
zIndex = 50,
triggerId
}: ColumnFilterPopoverProps) { }: ColumnFilterPopoverProps) {
const [search, setSearch] = useState(''); 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(() => { const filteredValues = useMemo(() => {
return uniqueValues.filter(v => return safeUniqueValues.filter(v =>
String(v || '').toLowerCase().includes(search.toLowerCase()) 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 ( return (
<div className={cn( <div className="fixed inset-0 z-0 pointer-events-none">
"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", {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 className
)}> )}
style={{
zIndex: 9999,
top: position.top,
left: position.left
}}
onClick={(e) => e.stopPropagation()}
>
<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>} {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"> <div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" /> <Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input <input
@@ -47,29 +257,72 @@ export function ColumnFilterPopover({
placeholder="Filter values..." placeholder="Filter values..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 text-xs text-white focus:outline-none focus:border-blue-500" 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 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>
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar"> <div
{filteredValues.map(val => ( ref={listRef}
<label key={val} className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group"> 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( <div className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors", "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) ? "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" />} {safeSelectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
</div> </div>
<input
type="checkbox"
className="hidden"
checked={selectedValues.includes(val)}
onChange={() => onToggle(val)}
/>
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span> <span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</label> </div>
))} );
})}
{filteredValues.length === 0 && ( {filteredValues.length === 0 && (
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div> <div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
)} )}
@@ -82,7 +335,7 @@ export function ColumnFilterPopover({
if (isAllSelected) { if (isAllSelected) {
onSelectAll([]); onSelectAll([]);
} else { } else {
onSelectAll(uniqueValues); onSelectAll(safeUniqueValues);
} }
}} }}
className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight" className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight"
@@ -94,7 +347,7 @@ export function ColumnFilterPopover({
onClick={onClear} onClick={onClear}
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight" className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
> >
Clear Current Clear
</button> </button>
</div> </div>
<button <button
@@ -104,6 +357,82 @@ export function ColumnFilterPopover({
OK OK
</button> </button>
</div> </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> </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 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 { ChevronDown, ChevronUp } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
@@ -9,6 +10,7 @@ interface DataCompletenessProps {
} }
export function DataCompleteness({ data, headers }: DataCompletenessProps) { export function DataCompleteness({ data, headers }: DataCompletenessProps) {
const COLUMNS = useColumns();
const [sortCol, setSortCol] = useState<number | 'score'>('score'); const [sortCol, setSortCol] = useState<number | 'score'>('score');
const [sortDesc, setSortDesc] = useState(false); const [sortDesc, setSortDesc] = useState(false);
const [page, setPage] = useState(1); 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>
);
}
+359 -66
View File
@@ -1,9 +1,12 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow } from '../types';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon } from 'lucide-react'; 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 { cn } from '../lib/utils';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { ColumnFilterPopover } from './ColumnFilterPopover'; import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { SyncStatusPill } from './SyncStatusPill';
interface DimensionsViewProps { interface DimensionsViewProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -11,6 +14,8 @@ interface DimensionsViewProps {
onEdit: (index: number) => void; onEdit: (index: number) => void;
onSaveRow: (index: number, updatedRow: ExcelRow) => void; onSaveRow: (index: number, updatedRow: ExcelRow) => void;
onCaptureState: (message: string) => void; onCaptureState: (message: string) => void;
rowStatuses: Record<string, string>;
onRevertRow: (articleNo: string) => void;
} }
interface DimensionGroup { interface DimensionGroup {
@@ -32,7 +37,8 @@ interface NearDuplicateCluster {
maxDiffPct: number; maxDiffPct: number;
} }
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState }: DimensionsViewProps) { export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState, rowStatuses, onRevertRow }: DimensionsViewProps) {
const COLUMNS = useColumns();
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set()); const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set()); const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true); const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
@@ -45,14 +51,27 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({}); const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({}); const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
const [pendingNearDupSync, setPendingNearDupSync] = useState<{ const [pendingNearDupSync, setPendingNearDupSync] = useState<{
clusterIndex: number; clusterKey: string;
targetGroupKey: string; targetGroupKey: string;
selectedIndices: number[]; selectedIndices: number[];
} | null>(null); } | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = usePersistentState('dimensions-search', '');
const [lineFilter, setLineFilter] = useState<string[]>([]); const [lineFilter, setLineFilter] = usePersistentState<string[]>('dimensions-lineFilter', []);
const [classFilter, setClassFilter] = useState<string[]>([]); const [classFilter, setClassFilter] = usePersistentState<string[]>('dimensions-classFilter', []);
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null); 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 groups = useMemo(() => {
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>(); const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
@@ -100,12 +119,14 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
const parts = key.split('x').map(Number); const parts = key.split('x').map(Number);
const volume = parts[0] * parts[1] * parts[2]; const volume = parts[0] * parts[1] * parts[2];
const isVerified = rows.some(({ row }) => row[COLUMNS.VERIFIED_DIMS] === true);
result.push({ result.push({
key, key,
innerDims: key, innerDims: key,
rows, rows,
volume, volume,
isInconsistent: !outerMatch || !unitsMatch || !moqMatch, isInconsistent: (!outerMatch || !unitsMatch || !moqMatch) && !isVerified,
discrepancies: { discrepancies: {
outer: !outerMatch, outer: !outerMatch,
units: !unitsMatch, units: !unitsMatch,
@@ -119,15 +140,25 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
const filteredGroups = useMemo(() => { const filteredGroups = useMemo(() => {
let result = groups; let result = groups;
if (showOnlyInconsistent) result = result.filter(g => g.isInconsistent); if (showOnlyInconsistent) {
result = result.filter(g =>
g.isInconsistent ||
g.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending')
);
}
if (search || lineFilter.length > 0 || classFilter.length > 0) { if (search || lineFilter.length > 0 || classFilter.length > 0) {
const s = search.toLowerCase(); const s = search.toLowerCase();
result = result.filter(g => { result = result.filter(g => {
const matchesSearch = !search || g.rows.some(({ row }) => const matchesSearch = !search || (() => {
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) || const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s) 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 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] || ''))); const matchesClass = classFilter.length === 0 || g.rows.some(({ row }) => classFilter.includes(String(row[COLUMNS.CLASSIFICATION] || '')));
@@ -136,7 +167,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
} }
return result; return result;
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter]); }, [groups, showOnlyInconsistent, search, lineFilter, classFilter, rowStatuses]);
const uniqueLines = useMemo(() => const uniqueLines = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort() Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
@@ -196,6 +227,55 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
return clusters; return clusters;
}, [groups]); }, [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 toggleGroup = (key: string) => {
const next = new Set(expandedGroups); const next = new Set(expandedGroups);
if (next.has(key)) { if (next.has(key)) {
@@ -214,11 +294,63 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
setPendingAction({ group, sourceRow, fieldType: 'all' }); 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 }) => {
const newRow = [...row];
newRow[COLUMNS.VERIFIED_DIMS] = true;
onSaveRow(index, newRow);
});
onCaptureState(`Verified dimensions for ${group.rows.length} products`);
};
const executeNearDupSync = async () => { const executeNearDupSync = async () => {
if (!pendingNearDupSync) return; if (!pendingNearDupSync) return;
const { clusterIndex, targetGroupKey, selectedIndices } = pendingNearDupSync; const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
const cluster = nearDuplicateClusters[clusterIndex]; const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
if (!cluster) return;
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey); const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
if (!targetGroup) return; if (!targetGroup) return;
@@ -241,7 +373,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
setClusterSelections(prev => { setClusterSelections(prev => {
const next = { ...prev }; const next = { ...prev };
delete next[clusterIndex]; delete next[pendingNearDupSync.clusterKey];
return next; return next;
}); });
}; };
@@ -290,7 +422,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
}; };
return ( 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="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]"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
@@ -299,13 +440,22 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
placeholder="Search SKU or Name in groups..." placeholder="Search SKU or Name in groups..."
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500" 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"
/> />
{search && (
<button
onClick={() => setSearch('')}
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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative"> <div className="relative">
<button <button
id="dims-filter-trigger-line"
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')} onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors", "flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
@@ -317,6 +467,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</button> </button>
{openFilter === 'line' && ( {openFilter === 'line' && (
<ColumnFilterPopover <ColumnFilterPopover
triggerId="dims-filter-trigger-line"
uniqueValues={uniqueLines} uniqueValues={uniqueLines}
selectedValues={lineFilter} selectedValues={lineFilter}
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])} onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -331,6 +482,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
<div className="relative"> <div className="relative">
<button <button
id="dims-filter-trigger-class"
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')} onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
className={cn( className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors", "flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
@@ -342,6 +494,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</button> </button>
{openFilter === 'class' && ( {openFilter === 'class' && (
<ColumnFilterPopover <ColumnFilterPopover
triggerId="dims-filter-trigger-class"
uniqueValues={uniqueClasses} uniqueValues={uniqueClasses}
selectedValues={classFilter} selectedValues={classFilter}
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])} onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
@@ -360,7 +513,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
className="p-2 text-red-400 hover:text-red-300 transition-colors" className="p-2 text-red-400 hover:text-red-300 transition-colors"
title="Clear all filters" title="Clear all filters"
> >
<XIcon className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
)} )}
</div> </div>
@@ -377,6 +530,13 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
/> />
Show only inconsistent Show only inconsistent
</label> </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"> <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 {groups.filter(g => g.isInconsistent).length} ISSUES
</div> </div>
@@ -394,11 +554,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</div> </div>
{nearDuplicateClusters.map((cluster, ci) => { {nearDuplicateClusters.map((cluster, ci) => {
const allClusterRows = cluster.groups.flatMap(g => g.rows); const allClusterRows = cluster.groups.flatMap(g => g.rows);
const selection = clusterSelections[ci] ?? new Set<number>(); const clusterKey = cluster.groups[0].key;
const targetKey = clusterSyncTargets[ci] ?? cluster.groups[0].key; const selection = clusterSelections[clusterKey] ?? new Set<number>();
const targetKey = clusterSyncTargets[clusterKey] ?? cluster.groups[0].key;
return ( return (
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden"> <div key={clusterKey} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
<button <button
onClick={() => { onClick={() => {
const next = new Set(expandedNearDuplicates); const next = new Set(expandedNearDuplicates);
@@ -422,12 +583,11 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
{expandedNearDuplicates.has(ci) && ( {expandedNearDuplicates.has(ci) && (
<div className="border-t border-violet-500/20"> <div className="border-t border-violet-500/20">
{/* Sync toolbar */}
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap"> <div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
<span className="text-xs text-slate-400">Sync selected to:</span> <span className="text-xs text-slate-400">Sync selected to:</span>
<select <select
value={targetKey} value={targetKey}
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [ci]: e.target.value }))} onChange={e => setClusterSyncTargets(prev => ({ ...prev, [clusterKey]: e.target.value }))}
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500" className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
> >
{cluster.groups.map(g => { {cluster.groups.map(g => {
@@ -441,21 +601,21 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</select> </select>
<button <button
disabled={selection.size === 0} disabled={selection.size === 0}
onClick={() => setPendingNearDupSync({ clusterIndex: ci, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })} onClick={() => setPendingNearDupSync({ clusterKey, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed" className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
> >
<Layers className="w-3 h-3" /> <Layers className="w-3 h-3" />
Sync {selection.size} selected Sync {selection.size} selected
</button> </button>
<button <button
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set(allClusterRows.map(r => r.index)) }))} onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set(allClusterRows.map(r => r.index)) }))}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors" className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
> >
Select all Select all
</button> </button>
{selection.size > 0 && ( {selection.size > 0 && (
<button <button
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set() }))} onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set() }))}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors" className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
> >
Clear Clear
@@ -463,29 +623,67 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
)} )}
</div> </div>
{/* Flat product list */}
<div className="divide-y divide-violet-500/10"> <div className="divide-y divide-violet-500/10">
{allClusterRows.map(({ row, index }) => { {allClusterRows.map(({ row, index }, rowIdx) => {
const isSelected = selection.has(index); const isSelected = selection.has(index);
const isInDragRange = isDragging && activeClusterKey === clusterKey && dragStart !== null && hoveredIndex !== null &&
((rowIdx >= dragStart && rowIdx <= hoveredIndex) || (rowIdx <= dragStart && rowIdx >= hoveredIndex));
return ( return (
<div <div
key={index} key={index}
className={cn( className={cn(
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors", "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" isSelected && "bg-violet-500/10",
isInDragRange && "bg-violet-500/20 ring-1 ring-violet-500/30"
)} )}
onClick={() => setClusterSelections(prev => { onMouseDown={(e) => {
const current = new Set(prev[ci] ?? []); // 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); if (current.has(index)) current.delete(index); else current.add(index);
return { ...prev, [ci]: current }; return { ...prev, [clusterKey]: current };
})} });
}
setLastClickedIndex(rowIdx);
}
}}
> >
<input <input
type="checkbox" type="checkbox"
checked={isSelected} checked={isSelected || isInDragRange}
onChange={() => {}} readOnly
onClick={e => e.stopPropagation()} className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0"
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -509,9 +707,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
)} )}
<div className="space-y-3"> <div className="space-y-3">
{filteredGroups.map(group => ( {filteredGroups.map(group => {
const hasPending = group.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending');
return (
<div key={group.key} className={cn( <div key={group.key} className={cn(
"border rounded-lg overflow-hidden transition-all", "border rounded-lg overflow-hidden transition-all",
hasPending ? "border-yellow-500/50 bg-yellow-500/5 ring-1 ring-yellow-500/20" :
group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30" group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30"
)}> )}>
<div className="flex items-center justify-between bg-slate-800/20 pr-4"> <div className="flex items-center justify-between bg-slate-800/20 pr-4">
@@ -525,17 +726,22 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
<span className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-0.5 rounded"> <span className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-0.5 rounded">
Inner: {group.innerDims} cm Inner: {group.innerDims} cm
</span> </span>
{hasPending && (
<span className="flex items-center gap-1 text-xs font-medium text-yellow-500 bg-yellow-500/10 px-2 py-0.5 rounded ring-1 ring-yellow-500/20">
Pending Validation
</span>
)}
{group.isInconsistent ? ( {group.isInconsistent ? (
<span className="flex items-center gap-1 text-xs font-medium text-amber-500 bg-amber-500/10 px-2 py-0.5 rounded ring-1 ring-amber-500/20"> <span className="flex items-center gap-1 text-xs font-medium text-amber-500 bg-amber-500/10 px-2 py-0.5 rounded ring-1 ring-amber-500/20">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
Inconsistent Inconsistent
</span> </span>
) : ( ) : !hasPending ? (
<span className="flex items-center gap-1 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded ring-1 ring-emerald-500/20"> <span className="flex items-center gap-1 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded ring-1 ring-emerald-500/20">
<CheckCircle2 className="w-3 h-3" /> <CheckCircle2 className="w-3 h-3" />
Consistent Consistent
</span> </span>
)} ) : null}
</div> </div>
<div className="text-xs text-slate-500 mt-1"> <div className="text-xs text-slate-500 mt-1">
{group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group {group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group
@@ -562,6 +768,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
)} )}
</div> </div>
{group.isInconsistent && (
<button
onClick={(e) => handleVerifyGroup(e, group)}
className="px-2 py-1 bg-emerald-600/10 hover:bg-emerald-600 hover:text-white text-emerald-500 rounded border border-emerald-600/30 text-[10px] font-bold transition-colors flex items-center gap-1"
title="Mark as correct to hide from inconsistent list"
>
<CheckCircle2 className="w-3 h-3" />
Mark Correct
</button>
)}
{group.isInconsistent && !expandedGroups.has(group.key) && ( {group.isInconsistent && !expandedGroups.has(group.key) && (
<div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20"> <div className="text-[10px] font-bold text-blue-400 bg-blue-400/5 px-2 py-1 rounded border border-blue-400/20">
OPEN TO SYNC FIELDS OPEN TO SYNC FIELDS
@@ -584,90 +801,166 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/50"> <tbody className="divide-y divide-slate-700/50">
{group.rows.map(({ row, index }) => ( {group.rows.map(({ row, index }) => {
<tr key={index} className="hover:bg-slate-700/20 group transition-colors"> const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
const syncStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
return (
<tr
key={index}
className={cn(
"hover:bg-slate-700/20 group transition-colors",
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
)}
>
<td className="px-4 py-3"> <td className="px-4 py-3">
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div> <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> <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>
<td className="px-4 py-3 text-slate-400 font-mono"> <td className="px-4 py-3 text-slate-400 font-mono">
{row[COLUMNS.INNER_L] || '-'} × {row[COLUMNS.INNER_W] || '-'} × {row[COLUMNS.INNER_H] || '-'} {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] : '-'}
</td> </td>
<td className={cn( <td className={cn(
"px-4 py-3 font-mono", "px-4 py-3 font-mono",
group.discrepancies.outer ? "text-amber-300" : "text-slate-400" 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 <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} 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" 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> </button>
<span>{row[COLUMNS.OUTER_L] || '-'} × {row[COLUMNS.OUTER_W] || '-'} × {row[COLUMNS.OUTER_H] || '-'}</span> <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> </div>
</td> </td>
<td className={cn( <td className={cn(
"px-4 py-3", "px-4 py-3",
group.discrepancies.units ? "text-amber-300 font-bold" : "text-slate-400" 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 <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} 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" 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> </button>
<span>{row[COLUMNS.UNITS_OUTER] || '-'}</span> <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> </div>
</td> </td>
<td className={cn( <td className={cn(
"px-4 py-3", "px-4 py-3",
group.discrepancies.moq ? "text-amber-300 font-bold" : "text-slate-400" 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 <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} 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" 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> </button>
<span>{row[COLUMNS.MOQ] || '-'}</span> <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> </div>
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center justify-end gap-1">
<button <button
onClick={() => handleFullSync(group, row)} onClick={() => handleFullSync(group, row)}
title="FULL SYNC: Apply ALL packaging measures labels to all in group" title="FULL SYNC: Apply ALL packaging measures labels to all in group"
disabled={syncing?.key === group.key} disabled={syncing?.key === group.key}
className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all" className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all opacity-0 group-hover:opacity-100"
> >
{syncing?.key === group.key && syncing?.field === 'all' ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />} {syncing?.key === group.key && syncing?.field === 'all' ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
</button> </button>
<button
onClick={() => onRevertRow(String(row[COLUMNS.ARTICLE_NO]))}
title="Undo pending changes"
className="p-1.5 hover:bg-red-600/20 text-yellow-500 hover:text-red-400 rounded transition-all"
>
<Undo2 className="w-4 h-4" />
</button>
<button <button
onClick={() => onEdit(index)} onClick={() => onEdit(index)}
title="Edit product" title="Edit product"
className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all" className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all opacity-0 group-hover:opacity-100"
> >
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
)} )}
</div> </div>
))} );
})}
{filteredGroups.length === 0 && ( {filteredGroups.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 bg-slate-800/20 border border-dashed border-slate-700 rounded-xl"> <div className="flex flex-col items-center justify-center py-20 bg-slate-800/20 border border-dashed border-slate-700 rounded-xl">
+315 -101
View File
@@ -1,8 +1,10 @@
import React, { useState } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow } from '../types';
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react'; import { useColumns } from '../contexts/ColumnsContext';
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2, Mic, MicOff } from 'lucide-react';
import { generateGemini } from '../services/gemini'; import { generateGemini } from '../services/gemini';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { useSpeechRecognition } from '../lib/useSpeechRecognition';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
interface EditPanelProps { interface EditPanelProps {
@@ -13,7 +15,161 @@ interface EditPanelProps {
onCaptureState: (message: string) => void; onCaptureState: (message: string) => void;
} }
interface DimensionInputProps {
label: string;
field: string;
value: string;
isModified: boolean;
onChange: (val: string) => void;
placeholder?: string;
}
const DimensionInput = ({ label, value, isModified, onChange, placeholder }: DimensionInputProps) => (
<div className="flex flex-col gap-1.5">
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label>
<input
type="text"
value={value}
onChange={e => onChange(e.target.value)}
placeholder={placeholder}
className={cn(
"w-full bg-slate-900 border rounded p-2 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"
)}
/>
</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;
value: string;
isModified: boolean;
canTranslate: boolean;
isGenerated: boolean;
isLoading: boolean;
onGenerate: () => void;
onChange: (val: string) => void;
onKeyDown: (e: React.KeyboardEvent) => void;
isListening?: boolean;
onToggleVoice?: () => void;
voiceSupported?: boolean;
}
const FieldEditor = ({
title,
field,
value,
isModified,
canTranslate,
isGenerated,
isLoading,
onGenerate,
onChange,
onKeyDown,
isListening,
onToggleVoice,
voiceSupported
}: FieldEditorProps) => (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-300">{title}</label>
<div className="flex items-center gap-2">
{canTranslate && (
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
<Languages className="w-3 h-3" />
Can translate from existing
</div>
)}
{voiceSupported && (
<button
onClick={onToggleVoice}
className={cn(
"flex items-center gap-1.5 text-xs font-medium px-2 py-1 rounded transition-colors",
isListening
? "bg-red-600/20 text-red-400 animate-pulse"
: "bg-slate-700/50 text-slate-400 hover:bg-slate-600 hover:text-white"
)}
title={isListening ? "Stop recording" : "Voice input"}
>
{isListening ? <MicOff className="w-3 h-3" /> : <Mic className="w-3 h-3" />}
{isListening ? 'Stop' : 'Voice'}
</button>
)}
<button
onClick={onGenerate}
disabled={isLoading}
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
>
{isLoading ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
{canTranslate ? 'Translate with Gemini' : 'Generate with Gemini'}
</button>
</div>
{isGenerated && (
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
<CheckCircle2 className="w-3 h-3" />
AI Generated - You can still edit manually
</div>
)}
</div>
<AutoResizeTextarea
id={`field-${field}`}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
className={cn(
"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}...`}
/>
</div>
);
export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) { export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: EditPanelProps) {
const COLUMNS = useColumns();
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
longDe: row[COLUMNS.LONG_DE] || '', longDe: row[COLUMNS.LONG_DE] || '',
longEn: row[COLUMNS.LONG_EN] || '', longEn: row[COLUMNS.LONG_EN] || '',
@@ -21,14 +177,16 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
shortEn: row[COLUMNS.SHORT_EN] || '', shortEn: row[COLUMNS.SHORT_EN] || '',
detailsDe: row[COLUMNS.DETAILS_DE] || '', detailsDe: row[COLUMNS.DETAILS_DE] || '',
detailsEn: row[COLUMNS.DETAILS_EN] || '', detailsEn: row[COLUMNS.DETAILS_EN] || '',
innerW: row[COLUMNS.INNER_W] || '', innerW: row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? String(row[COLUMNS.INNER_W]) : '',
innerL: row[COLUMNS.INNER_L] || '', innerL: row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? String(row[COLUMNS.INNER_L]) : '',
innerH: row[COLUMNS.INNER_H] || '', innerH: row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? String(row[COLUMNS.INNER_H]) : '',
outerW: row[COLUMNS.OUTER_W] || '', outerW: row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? String(row[COLUMNS.OUTER_W]) : '',
outerL: row[COLUMNS.OUTER_L] || '', outerL: row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? String(row[COLUMNS.OUTER_L]) : '',
outerH: row[COLUMNS.OUTER_H] || '', outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
unitsOuter: row[COLUMNS.UNITS_OUTER] || '', unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
moq: row[COLUMNS.MOQ] || '', 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); const [loadingField, setLoadingField] = useState<string | null>(null);
@@ -37,6 +195,17 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null); const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set()); const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
const handleSpeechResult = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longDe: prev.longDe + ' ' + transcript }));
}, []);
const { isListening: isListeningDe, start: startListeningDe, stop: stopListeningDe, isSupported: speechSupported } = useSpeechRecognition({ onResult: handleSpeechResult });
const handleSpeechResultEn = useCallback((transcript: string) => {
setFormData(prev => ({ ...prev, longEn: prev.longEn + ' ' + transcript }));
}, []);
const { isListening: isListeningEn, start: startListeningEn, stop: stopListeningEn } = useSpeechRecognition({ onResult: handleSpeechResultEn, lang: 'en-US' });
const isModified = (field: keyof typeof formData) => { const isModified = (field: keyof typeof formData) => {
const colMap: Record<string, number> = { const colMap: Record<string, number> = {
longDe: COLUMNS.LONG_DE, longDe: COLUMNS.LONG_DE,
@@ -53,20 +222,27 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
moq: COLUMNS.MOQ, moq: COLUMNS.MOQ,
detailsDe: COLUMNS.DETAILS_DE, detailsDe: COLUMNS.DETAILS_DE,
detailsEn: COLUMNS.DETAILS_EN, detailsEn: COLUMNS.DETAILS_EN,
productType: COLUMNS.CATEGORIZATION_CODE,
itemToLogistic: COLUMNS.ITEM_TO_LOGISTIC,
}; };
const colIndex = (colMap as Record<string, number>)[field as string]; const colIndex = (colMap as Record<string, number>)[field as string];
if (colIndex === undefined) return false; if (colIndex === undefined) return false;
return formData[field] !== (row[colIndex] || ''); 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); setPendingGeminiField(field);
}; };
const executeGenerate = async () => { const executeGenerate = async () => {
if (!pendingGeminiField) return; if (!pendingGeminiField) {
console.warn('[EditPanel] executeGenerate called but no field pending');
return;
}
const field = pendingGeminiField; const field = pendingGeminiField;
setPendingGeminiField(null); console.log('[EditPanel] Starting generation for field:', field);
setLoadingField(field); setLoadingField(field);
setError(null); setError(null);
@@ -74,7 +250,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
let prompt = ''; 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 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. You excel at translating product descriptions between German and English, maintaining the commercial and professional tone while ensuring all technical toy details are accurate. Use clear, engaging language."; 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 (field === 'longDe') {
if (formData.longEn) { if (formData.longEn) {
@@ -84,11 +260,14 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
2. Maintain the EXACT same length and detailed information. 2. Maintain the EXACT same length and detailed information.
3. Keep all technical specs intact. 3. Keep all technical specs intact.
4. Translate every single paragraph into natural, commercial German for toy buyers. 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: English Text to Translate:
${formData.longEn}`; ${formData.longEn}`;
} else { } 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') { } else if (field === 'longEn') {
if (formData.longDe) { if (formData.longDe) {
@@ -98,37 +277,63 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
2. Maintain the EXACT same length and detailed information. 2. Maintain the EXACT same length and detailed information.
3. Keep all technical specs intact. 3. Keep all technical specs intact.
4. Translate every single paragraph into natural, commercial English for toy buyers. 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: German Text to Translate:
${formData.longDe}`; ${formData.longDe}`;
} else { } 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') { } else if (field === 'shortDe') {
if (formData.shortEn) { if (formData.shortEn) {
prompt = `Translate exactly this short English product description into professional German for the toy market:\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) { } else if (formData.longDe) {
const targetChars = Math.round(formData.longDe.length * 0.3); prompt = `Create a concise summary of the following German product description.
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}`; 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 { } 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') { } else if (field === 'shortEn') {
if (formData.shortDe) { if (formData.shortDe) {
prompt = `Translate exactly this short German product description into professional English for the toy market:\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) { } else if (formData.longEn) {
const targetChars = Math.round(formData.longEn.length * 0.3); prompt = `Create a concise summary of the following English product description.
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}`; 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 { } 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}`;
} }
} }
const generatedText = await generateGemini(prompt, systemPrompt); const generatedText = await generateGemini(prompt, systemPrompt);
setFormData(prev => ({ ...prev, [field]: generatedText.trim() })); setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
setGeneratedFields(prev => new Set(prev).add(field)); setGeneratedFields(prev => new Set(prev).add(field));
// Focus the textarea so user can edit immediately after AI generation
setTimeout(() => {
const textarea = document.getElementById(`field-${field}`);
if (textarea) textarea.focus();
}, 100);
setPendingGeminiField(null);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'An error occurred during generation.'); setError(err.message || 'An error occurred during generation.');
setPendingGeminiField(null); // Close modal on error so error message is visible in panel
} finally { } finally {
setLoadingField(null); setLoadingField(null);
} }
@@ -155,66 +360,14 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
newRow[COLUMNS.MOQ] = formData.moq; newRow[COLUMNS.MOQ] = formData.moq;
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe; newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn; newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
newRow[COLUMNS.CATEGORIZATION_CODE] = formData.productType;
newRow[COLUMNS.ITEM_TO_LOGISTIC] = formData.itemToLogistic;
onSave(rowIndex, newRow); onSave(rowIndex, newRow);
}; };
const DimensionInput = ({ label, field, placeholder }: { label: string, field: keyof typeof formData, placeholder?: string }) => ( const handleInputKeyDown = (e: React.KeyboardEvent) => {
<div className="flex flex-col gap-1.5"> e.stopPropagation();
<label className="text-[10px] font-medium text-slate-500 uppercase tracking-wider">{label}</label> };
<input
type="text"
value={formData[field]}
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
placeholder={placeholder}
className={cn(
"w-full bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500"
)}
/>
</div>
);
const FieldEditor = ({ title, field }: { title: string, field: keyof typeof formData }) => (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-300">{title}</label>
<div className="flex items-center gap-2">
{((field === 'longDe' && formData.longEn) ||
(field === 'longEn' && formData.longDe) ||
(field === 'shortDe' && (formData.longDe || formData.shortEn)) ||
(field === 'shortEn' && (formData.shortDe || formData.longEn))) && (
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
<Languages className="w-3 h-3" />
Can translate from existing
</div>
)}
<button
onClick={() => handleGenerate(field)}
disabled={loadingField !== null}
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
>
{loadingField === field ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
{((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'}
</button>
</div>
{generatedFields.has(field) && (
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
<CheckCircle2 className="w-3 h-3" />
AI Generated - You can still edit manually
</div>
)}
</div>
<textarea
value={formData[field]}
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
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",
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
placeholder={`Enter ${title}...`}
/>
</div>
);
return ( return (
<> <>
@@ -248,11 +401,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span> <span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
<textarea <AutoResizeTextarea
value={formData.detailsDe} value={formData.detailsDe}
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))} onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
className={cn( 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" isModified('detailsDe') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
)} )}
placeholder="Enter details in German..." placeholder="Enter details in German..."
@@ -260,11 +413,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span> <span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
<textarea <AutoResizeTextarea
value={formData.detailsEn} value={formData.detailsEn}
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))} onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
className={cn( 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" isModified('detailsEn') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
)} )}
placeholder="Enter details in English..." placeholder="Enter details in English..."
@@ -279,28 +432,83 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="col-span-3 text-[10px] text-slate-500 font-medium">INNER BOX (L × W × H) cm</div> <div className="col-span-3 text-[10px] text-slate-500 font-medium">INNER BOX (L × W × H) cm</div>
<DimensionInput label="Length" field="innerL" placeholder="L" /> <DimensionInput label="Length" value={formData.innerL} field="innerL" isModified={isModified('innerL')} onChange={(val) => setFormData(p => ({...p, innerL: val}))} placeholder="L" />
<DimensionInput label="Width" field="innerW" placeholder="W" /> <DimensionInput label="Width" value={formData.innerW} field="innerW" isModified={isModified('innerW')} onChange={(val) => setFormData(p => ({...p, innerW: val}))} placeholder="W" />
<DimensionInput label="Height" field="innerH" placeholder="H" /> <DimensionInput label="Height" value={formData.innerH} field="innerH" isModified={isModified('innerH')} onChange={(val) => setFormData(p => ({...p, innerH: val}))} placeholder="H" />
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="col-span-3 text-[10px] text-slate-500 font-medium">OUTER BOX (L × W × H) cm</div> <div className="col-span-3 text-[10px] text-slate-500 font-medium">OUTER BOX (L × W × H) cm</div>
<DimensionInput label="Length" field="outerL" placeholder="L" /> <DimensionInput label="Length" value={formData.outerL} field="outerL" isModified={isModified('outerL')} onChange={(val) => setFormData(p => ({...p, outerL: val}))} placeholder="L" />
<DimensionInput label="Width" field="outerW" placeholder="W" /> <DimensionInput label="Width" value={formData.outerW} field="outerW" isModified={isModified('outerW')} onChange={(val) => setFormData(p => ({...p, outerW: val}))} placeholder="W" />
<DimensionInput label="Height" field="outerH" placeholder="H" /> <DimensionInput label="Height" value={formData.outerH} field="outerH" isModified={isModified('outerH')} onChange={(val) => setFormData(p => ({...p, outerH: val}))} placeholder="H" />
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<DimensionInput label="Units per Outer" field="unitsOuter" /> <DimensionInput label="Units per Outer" value={formData.unitsOuter} field="unitsOuter" isModified={isModified('unitsOuter')} onChange={(val) => setFormData(p => ({...p, unitsOuter: val}))} />
<DimensionInput label="MOQ" field="moq" /> <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>
</div> </div>
<FieldEditor title="Long Description (DE)" field="longDe" /> <FieldEditor
<FieldEditor title="Long Description (EN)" field="longEn" /> title="Long Description (DE)"
<FieldEditor title="Short Description (DE)" field="shortDe" /> field="longDe"
<FieldEditor title="Short Description (EN)" field="shortEn" /> value={formData.longDe}
isModified={isModified('longDe')}
canTranslate={!!formData.longEn}
isGenerated={generatedFields.has('longDe')}
isLoading={loadingField === 'longDe'}
onGenerate={() => handleGenerate('longDe')}
onChange={(val) => setFormData(p => ({...p, longDe: val}))}
onKeyDown={handleInputKeyDown}
isListening={isListeningDe}
onToggleVoice={isListeningDe ? stopListeningDe : startListeningDe}
voiceSupported={speechSupported}
/>
<FieldEditor
title="Long Description (EN)"
field="longEn"
value={formData.longEn}
isModified={isModified('longEn')}
canTranslate={!!formData.longDe}
isGenerated={generatedFields.has('longEn')}
isLoading={loadingField === 'longEn'}
onGenerate={() => handleGenerate('longEn')}
onChange={(val) => setFormData(p => ({...p, longEn: val}))}
onKeyDown={handleInputKeyDown}
isListening={isListeningEn}
onToggleVoice={isListeningEn ? stopListeningEn : startListeningEn}
voiceSupported={speechSupported}
/>
<FieldEditor
title="Short Description (DE)"
field="shortDe"
value={formData.shortDe}
isModified={isModified('shortDe')}
canTranslate={!!formData.shortEn}
isGenerated={generatedFields.has('shortDe')}
isLoading={loadingField === 'shortDe'}
onGenerate={() => handleGenerate('shortDe')}
onChange={(val) => setFormData(p => ({...p, shortDe: val}))}
onKeyDown={handleInputKeyDown}
/>
<FieldEditor
title="Short Description (EN)"
field="shortEn"
value={formData.shortEn}
isModified={isModified('shortEn')}
canTranslate={!!formData.shortDe}
isGenerated={generatedFields.has('shortEn')}
isLoading={loadingField === 'shortEn'}
onGenerate={() => handleGenerate('shortEn')}
onChange={(val) => setFormData(p => ({...p, shortEn: val}))}
onKeyDown={handleInputKeyDown}
/>
</div> </div>
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3"> <div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
@@ -315,7 +523,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors" className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
> >
<Save className="w-4 h-4" /> <Save className="w-4 h-4" />
Save to Memory Queue Changes
</button> </button>
</div> </div>
<ConfirmModal <ConfirmModal
@@ -330,10 +538,16 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
<ConfirmModal <ConfirmModal
isOpen={!!pendingGeminiField} isOpen={!!pendingGeminiField}
isLoading={loadingField !== null}
onConfirm={executeGenerate} onConfirm={executeGenerate}
onCancel={() => setPendingGeminiField(null)} onCancel={() => setPendingGeminiField(null)}
title="Confirm AI Action" 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" type="info"
confirmText="Start Generation" confirmText="Start Generation"
/> />
+639
View File
@@ -0,0 +1,639 @@
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 } 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?: string) => void;
onEdit?: (rowIndex: number) => void;
}
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] = 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 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);
if (currentRow && JSON.stringify(currentRow) === JSON.stringify(entry.old_data)) {
if (!window.confirm("Reverting will restore original data. No actual changes will be made. Continue?")) {
return;
}
}
onRevert(entry.product_id, entry.old_data, entry.id);
}
};
const getChangedFields = (oldData: ExcelRow, newData: ExcelRow) => {
const changes: { header: string; old: any; new: any; index: number }[] = [];
const maxLen = Math.max(oldData.length, newData.length);
for (let i = 0; i < maxLen; i++) {
if (oldData[i] !== newData[i]) {
changes.push({
header: headers[i] || `Col ${i}`,
old: oldData[i],
new: newData[i],
index: i
});
}
}
return changes;
};
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
return new Intl.DateTimeFormat('en-GB', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
}).format(date);
};
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">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p className="text-lg">Fetching history...</p>
</div>
);
}
return (
<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">
{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
type="text"
placeholder="Search history..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-md text-sm text-slate-200 focus:outline-none focus:border-blue-500 transition-colors w-64"
/>
{search && (
<button
onClick={() => setSearch('')}
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>
<button
onClick={loadHistory}
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-200 rounded-md transition-colors flex items-center gap-2 border border-slate-700"
>
<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>
<div className="bg-[#0a1628] border border-slate-800 rounded-xl overflow-hidden shadow-2xl">
{filteredHistory.length === 0 ? (
<div className="p-12 text-center">
<History className="w-12 h-12 text-slate-700 mx-auto mb-4" />
<p className="text-slate-500 text-lg">No history records found.</p>
<p className="text-slate-600 text-sm mt-1">
{search ? "Try adjusting your search filters." : "Changes are recorded when you 'Save All' pending modifications."}
</p>
</div>
) : (
<div className="divide-y divide-slate-800">
{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(
"transition-colors",
isExpanded ? "bg-blue-600/5" : "hover:bg-slate-800/30"
)}>
{/* Summary Row */}
<div
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">
{entry.product_id.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<div className="font-semibold text-slate-200 truncate">{entry.article_name}</div>
<div className="text-xs text-slate-500 font-mono">ID: {entry.product_id}</div>
</div>
</div>
<div className="flex items-center gap-2 text-slate-400">
<User className="w-4 h-4" />
<span className="text-sm truncate">{entry.changed_by}</span>
</div>
<div className="flex items-center gap-2 text-slate-400">
<Calendar className="w-4 h-4" />
<span className="text-sm">{formatDate(entry.changed_at)}</span>
</div>
<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>
{/* Details View */}
{isExpanded && (
<div className="px-14 pb-6 pt-2 animate-in slide-in-from-top-2 duration-300">
<div className="bg-[#040d1a] border border-slate-700/50 rounded-lg overflow-hidden">
<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 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>
</thead>
<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>
<td className="px-4 py-2">
<span className="text-red-400/80 line-through decoration-red-500/50">
{String(change.old ?? '-')}
</span>
</td>
<td className="px-4 py-2">
<span className="text-emerald-400 font-medium">
{String(change.new ?? '-')}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-4 flex items-center justify-between px-2">
<div className="text-xs text-slate-500 flex items-center gap-1">
<Tag className="w-3 h-3" />
Row Index Reference: {entry.product_id}
</div>
<p className="text-xs text-slate-500 italic">
Reverting will move this record to 'Pending Validation' for final approval before re-syncing.
</p>
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+2 -2
View File
@@ -111,7 +111,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
type="email" type="email"
value={email} value={email}
onChange={e => setEmail(e.target.value)} onChange={e => setEmail(e.target.value)}
placeholder="you@craze-group.com" placeholder="you@example.com"
required required
autoFocus autoFocus
className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors" className="w-full bg-slate-900 border border-slate-700 rounded-md px-3 py-2.5 text-sm text-white placeholder-slate-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors"
@@ -157,7 +157,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
</form> </form>
<p className="text-center text-slate-600 text-xs mt-6"> <p className="text-center text-slate-600 text-xs mt-6">
Access restricted to @craze-group.com accounts Create an account to get started
</p> </p>
</div> </div>
</div> </div>
+377 -22
View File
@@ -1,22 +1,35 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { ExcelRow } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, ChevronDown, ChevronUp, X as XIcon } from 'lucide-react'; import { useColumns } from '../contexts/ColumnsContext';
import { Search, Filter, ChevronDown, ChevronUp, X, Maximize2, Check, Loader2 } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover'; import { ColumnFilterPopover } from './ColumnFilterPopover';
import { usePersistentState } from '../contexts/FilterContext';
import { buildBusinessCentralMappingPreview } from '../services/businessCentralMapping';
import { applyBusinessCentralSync, previewBusinessCentralSync, isPreviewTokenMismatchError } from '../services/businessCentral';
interface MatrixViewProps { interface MatrixViewProps {
data: ExcelRow[]; data: ExcelRow[];
headers: string[]; headers: string[];
rowStatuses: Record<string, string>;
} }
export function MatrixView({ data, headers }: MatrixViewProps) { export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
const resolvedCols = useColumns();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(25);
const [search, setSearch] = useState(''); const [search, setSearch] = usePersistentState('matrix-search', '');
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({}); const [bcSku, setBcSku] = usePersistentState('matrix-bcSku', '');
const [columnFilters, setColumnFilters] = usePersistentState<Record<number, string[]>>('matrix-columnFilters', {});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null); const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [sortCol, setSortCol] = useState<number | null>(null); const [sortCol, setSortCol] = usePersistentState<number | null>('matrix-sortCol', null);
const [sortDesc, setSortDesc] = useState(false); 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(() => { const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index })); let result = data.map((row, index) => ({ row, index }));
@@ -65,6 +78,25 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
return filteredData.slice(start, start + pageSize); return filteredData.slice(start, start + pageSize);
}, [filteredData, page, 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) => { const handleSort = (col: number) => {
if (sortCol === col) { if (sortCol === col) {
setSortDesc(!sortDesc); setSortDesc(!sortDesc);
@@ -74,6 +106,25 @@ export function MatrixView({ data, headers }: 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 getUniqueValues = (col: number) => {
const values = data.map(r => String(r[col] || '')); const values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort(); return Array.from(new Set(values)).sort();
@@ -95,11 +146,67 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
setPage(1); 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 = '') => { const formatCellValue = (val: any, header: string = '') => {
if (val === undefined || val === null || val === '') return ''; 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) // Handle date columns - Excel serial dates are numbers >= 25569 (Jan 1, 1970)
if (h.includes('date') || h.includes('launch') || h.includes('ready')) { if (h.includes('date') || h.includes('launch') || h.includes('ready')) {
@@ -155,13 +262,40 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
const totalPages = Math.ceil(filteredData.length / pageSize); const totalPages = Math.ceil(filteredData.length / pageSize);
return ( 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 className="p-4 border-b border-slate-700 bg-slate-800/50 flex items-center justify-between">
<div> <div>
<h2 className="text-lg font-semibold text-white">Matrix View</h2> <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> <p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
</div> </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]"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input <input
@@ -169,8 +303,16 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
placeholder="Search all columns..." placeholder="Search all columns..."
value={search} value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 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" 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"
/> />
{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>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && ( {(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button <button
@@ -180,39 +322,244 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
}} }}
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20" className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
> >
<XIcon className="w-4 h-4" /> <X className="w-4 h-4" />
Clear All Column Filters Clear All Column Filters
</button> </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> </div>
<div className="overflow-auto flex-1"> <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"> <thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
<tr> <tr>
{headers.map((header, index) => ( {headers.map((header, index) => (
<th <th
key={index} 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 justify-between gap-2"> <div className="flex items-center overflow-hidden">
<div <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)} onClick={() => handleSort(index)}
> >
{header} {header}
{sortCol === index && ( {sortCol === index && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" /> sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)} )}
</div> </span>
<button <button
id={`matrix-filter-trigger-${index}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setOpenFilterCol(openFilterCol === index ? null : index); setOpenFilterCol(openFilterCol === index ? null : index);
}} }}
className={cn( className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors", "p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100" (columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
)} )}
> >
@@ -220,8 +567,15 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
</button> </button>
</div> </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 && ( {openFilterCol === index && (
<ColumnFilterPopover <ColumnFilterPopover
triggerId={`matrix-filter-trigger-${index}`}
uniqueValues={getUniqueValues(index)} uniqueValues={getUniqueValues(index)}
selectedValues={columnFilters[index] || []} selectedValues={columnFilters[index] || []}
onToggle={(val) => toggleColumnFilter(index, val)} onToggle={(val) => toggleColumnFilter(index, val)}
@@ -250,7 +604,8 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
return ( return (
<td <td
key={colIndex} 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] || '')} title={String(row[colIndex] || '')}
> >
{formattedValue} {formattedValue}
+672
View File
@@ -0,0 +1,672 @@
import React, { useState, useMemo } from '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[];
headers: string[];
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
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.toISOString().split('T')[0]; // Returns YYYY-MM-DD
}
return '';
}
return formatDateForInput(String(val));
}
function isEmptyOrEpoch(val: any): boolean {
if (val === null || val === undefined || val === '') return true;
if (typeof val === 'number') {
if (val === 0 || val === 1) return true;
if (val >= 25569 && val <= 60000) return false;
return true;
}
const s = String(val).trim();
if (s === '' || s === '0' || s === '1') return true;
if (s.endsWith('/1900')) return true;
return false;
}
type TabType = 'missingLaunch' | 'launchInconsistent' | 'upcomingLaunch';
interface EditingState {
rowIndex: number;
classification: string;
launchDate: string;
readyToOrder: string;
}
export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: MissingDataViewProps) {
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] = 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(() => {
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 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 }] : []),
];
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 launchVal = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
const classVal = r.row[COLUMNS.CLASSIFICATION];
return isEmptyOrEpoch(launchVal) || isEmptyOrEpoch(classVal);
});
}
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'));
}
}
// 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);
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 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;
});
});
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, activeTab, search, sortCol, sortDesc, launchDateCol, columnFilters]);
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 openEdit = (rowIndex: number, row: ExcelRow) => {
setEditing({
rowIndex,
classification: String(row[COLUMNS.CLASSIFICATION] || ''),
launchDate: launchDateCol >= 0 ? formatDateForInput(formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '')) : '',
readyToOrder: readyToOrderCol >= 0 ? formatDateForInput(formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol] ?? '')) : '',
});
};
const handleSave = () => {
if (!editing) return;
const row = data[editing.rowIndex];
onCaptureState(`Updated product ${row[COLUMNS.ARTICLE_NO]}`);
const newRow = [...row];
newRow[COLUMNS.CLASSIFICATION] = editing.classification;
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: '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={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
key={tab.id}
onClick={() => { setActiveTab(tab.id); setPage(1); }}
className={cn(
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
activeTab === tab.id
? "bg-indigo-600 text-white shadow-md"
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
)}
>
{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">
<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 text-xs text-slate-500">
{filteredData.length} items
</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 allValues = getUniqueValues(col);
const filterCount = selectedFilters.length;
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"
onClick={() => handleSort(col)}
>
{label}
{sortCol === col && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)}
</div>
<div className="mt-1 relative">
{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, 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>
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: 220 }} title={row[COLUMNS.ARTICLE_NAME]}>
{row[COLUMNS.ARTICLE_NAME]}
</td>
<td className="px-3 py-2 truncate" style={{ width: 130 }}>
{row[COLUMNS.CLASSIFICATION] && String(row[COLUMNS.CLASSIFICATION]).trim() !== '' ? (
<span className={cn(
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
: "bg-slate-700/50 text-slate-400 border-slate-600/50"
)}>
{row[COLUMNS.CLASSIFICATION]}
</span>
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>
)}
</td>
{launchDateCol >= 0 && (
<td className="px-3 py-2 truncate font-mono text-slate-300" style={{ width: 130 }}>
{isEmptyOrEpoch(row[launchDateCol]) ? (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>
) : (
formatDateValue(row[launchDateCol]) || String(row[launchDateCol])
)}
</td>
)}
{readyToOrderCol >= 0 && (
<td className="px-3 py-2 truncate font-mono text-slate-300" style={{ width: 130 }}>
{row[readyToOrderCol] !== null && row[readyToOrderCol] !== undefined && row[readyToOrderCol] !== '' ? (
formatDateValue(row[readyToOrderCol]) || String(row[readyToOrderCol])
) : (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-slate-700/50 text-slate-500 border border-slate-600/50"></span>
)}
</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)}
className="p-1.5 text-slate-500 hover:text-indigo-400 hover:bg-indigo-400/10 rounded transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
</td>
</tr>
))}
{paginatedData.length === 0 && (
<tr>
<td colSpan={columns.length + 1} className="px-4 py-8 text-center text-slate-500">
No items 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} 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>
{/* Focused edit panel */}
{editing && editingRow && (
<>
<div className="fixed inset-0 bg-slate-950/50 backdrop-blur-sm z-40" onClick={() => setEditing(null)} />
<div className="fixed right-0 top-0 bottom-0 w-[400px] bg-slate-800 border-l border-slate-700 shadow-2xl z-50 flex flex-col animate-in slide-in-from-right duration-200">
<div className="flex items-center justify-between p-6 border-b border-slate-700 bg-slate-800/50">
<div>
<h2 className="text-lg font-bold text-white">Edit Fields</h2>
<p className="text-xs text-slate-400 mt-0.5">
{editingRow[COLUMNS.ARTICLE_NO]} {editingRow[COLUMNS.ARTICLE_NAME]}
</p>
</div>
<button
onClick={() => setEditing(null)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-700 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<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>
<select
value={editing.classification}
onChange={e => setEditing(prev => prev ? { ...prev, classification: e.target.value } : prev)}
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="date"
value={editing.launchDate}
onChange={e => setEditing(prev => prev ? { ...prev, launchDate: e.target.value } : prev)}
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 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"
)}
/>
</div>
)}
{readyToOrderCol >= 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{readyHeader}</label>
<input
type="date"
value={editing.readyToOrder}
onChange={e => setEditing(prev => prev ? { ...prev, readyToOrder: e.target.value } : prev)}
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 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"
)}
/>
</div>
)}
</div>
<div className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
<button
onClick={() => setEditing(null)}
className="px-4 py-2 text-sm font-medium text-slate-300 hover:text-white hover:bg-slate-700 rounded-md transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-md shadow-lg shadow-blue-900/20 transition-colors"
>
<Save className="w-4 h-4" />
Queue Changes
</button>
</div>
</div>
</>
)}
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import React, { useState } from '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[];
pendingRows: Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>;
rowStatuses: Record<string, string>;
onRevertRow: (articleNo: string) => void;
onEdit: (index: number) => void;
}
export function PendingValidationView({ data, pendingRows, rowStatuses, onRevertRow, onEdit }: PendingValidationViewProps) {
const COLUMNS = useColumns();
const [search, setSearch] = usePersistentState('pending-search', '');
const [isFullscreen, setIsFullscreen] = useState(false);
const pendingEntries = Object.entries(pendingRows);
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];
const upd = updated[colIndex];
if (orig !== upd) {
return { from: String(orig ?? ''), to: String(upd ?? '') };
}
return null;
};
const getChangedFields = (original: ExcelRow, updated: ExcelRow) => {
const changes: { field: string; from: string; to: string }[] = [];
const fieldConfigs = [
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
{ col: COLUMNS.INNER_L, label: 'Inner L' },
{ col: COLUMNS.INNER_W, label: 'Inner W' },
{ col: COLUMNS.INNER_H, label: 'Inner H' },
{ col: COLUMNS.OUTER_L, label: 'Outer L' },
{ col: COLUMNS.OUTER_W, label: 'Outer W' },
{ col: COLUMNS.OUTER_H, label: 'Outer H' },
{ col: COLUMNS.UNITS_INNER, label: 'Units Inner' },
{ col: COLUMNS.UNITS_OUTER, label: 'Units Outer' },
{ col: COLUMNS.MOQ, label: 'MOQ' },
{ col: COLUMNS.BARCODE, label: 'Barcode' },
{ col: COLUMNS.TARIFF_CODE, label: 'Tariff Code' },
{ col: COLUMNS.COUNTRY_ORIGIN, label: 'Country' },
{ col: COLUMNS.RECOMMENDED_AGE, label: 'Recommended Age' },
];
fieldConfigs.forEach(({ col, label }) => {
const diff = getFieldDiff(original, updated, col);
if (diff) {
changes.push({ field: label, ...diff });
}
});
return changes;
};
return (
<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" />
<h2 className="text-lg font-semibold text-white">Pending Validation</h2>
<span className="text-sm text-slate-500">
{pendingEntries.length} change{pendingEntries.length !== 1 ? 's' : ''} pending
</span>
</div>
<div className="flex items-center gap-2">
<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="Search by article no or name..."
value={search}
onChange={e => setSearch(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded-md pl-9 pr-10 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64"
/>
{search && (
<button
onClick={() => setSearch('')}
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>
<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>
{filteredEntries.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center text-slate-500">
<Clock className="w-16 h-16 mb-4 opacity-30" />
{search ? (
<p>No pending changes match your search</p>
) : (
<p>No pending validation changes</p>
)}
</div>
) : (
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{filteredEntries.map(([articleNo, { rowIndex, originalData, newData, articleName }]) => {
const changes = getChangedFields(originalData, newData);
const status = rowStatuses[articleNo];
return (
<div
key={articleNo}
className="border border-yellow-500/30 bg-yellow-500/5 rounded-lg overflow-hidden"
>
<div className="flex items-center justify-between p-4 bg-yellow-500/10 border-b border-yellow-500/20">
<div className="flex items-center gap-4">
<div className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-1 rounded">
{articleNo}
</div>
<div className="text-sm text-slate-300">{articleName}</div>
{status && <SyncStatusPill status={status} />}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => onEdit(rowIndex)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-slate-300 rounded text-xs font-medium transition-colors"
>
<FileText className="w-3 h-3" />
View/Edit
</button>
<button
onClick={() => onRevertRow(articleNo)}
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-900/30 hover:bg-red-900/50 text-red-400 rounded text-xs font-medium transition-colors"
>
<Undo2 className="w-3 h-3" />
Undo
</button>
</div>
</div>
<div className="p-4">
<div className="text-xs font-medium text-slate-500 uppercase tracking-wider mb-3">
Changes ({changes.length})
</div>
<div className="space-y-2">
{changes.map((change, idx) => (
<div key={idx} className="flex items-center gap-3 text-sm">
<span className="text-slate-400 w-28 shrink-0">{change.field}:</span>
<span className="text-red-400 line-through opacity-70">{change.from}</span>
<span className="text-slate-500"></span>
<span className="text-green-400 font-medium">{change.to}</span>
</div>
))}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+181 -74
View File
@@ -1,30 +1,66 @@
import React, { useState, useMemo, useCallback } from 'react'; import React, { useEffect, useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react'; import { useColumns } from '../contexts/ColumnsContext';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X, Maximize2 } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover'; 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 { interface ProductDescriptionsProps {
data: ExcelRow[]; data: ExcelRow[];
headers: string[];
asinColumnIndex: number | null;
onEdit: (index: number) => void; onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
dashboardDrilldown?: DashboardDrilldownRequest | null;
onDashboardDrilldownApplied?: () => void;
} }
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingLongAny' | 'missingShortDE' | 'missingShortEN' | 'missingShortAny' | '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 // Description columns that should only have Present/Missing filters
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN]; 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');
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) { const [search, setSearch] = usePersistentState('descriptions-search', '');
const [activeTab, setActiveTab] = useState<TabType>('all'); const [lineFilter, setLineFilter] = usePersistentState('descriptions-lineFilter', '');
const [search, setSearch] = useState(''); const [licenseFilter, setLicenseFilter] = usePersistentState('descriptions-licenseFilter', '');
const [lineFilter, setLineFilter] = useState(''); const [sortCol, setSortCol] = usePersistentState<number | null>('descriptions-sortCol', null);
const [licenseFilter, setLicenseFilter] = useState(''); const [sortDesc, setSortDesc] = usePersistentState('descriptions-sortDesc', false);
const [sortCol, setSortCol] = useState<number | null>(null); const [pageSize, setPageSize] = usePersistentState('descriptions-pageSize', 100);
const [sortDesc, setSortDesc] = useState(false);
const [pageSize, setPageSize] = useState(25);
const [page, setPage] = useState(1); 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 [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
[COLUMNS.ARTICLE_NO]: 110,
[COLUMNS.ARTICLE_NAME]: 450, // More flexible space for name
...(asinColumnIndex !== null ? { [asinColumnIndex]: 100 } : {}),
[COLUMNS.LINE]: 80,
[COLUMNS.LICENSE]: 120,
[COLUMNS.CLASSIFICATION]: 100,
[COLUMNS.LONG_DE]: 110,
[COLUMNS.LONG_EN]: 110,
[COLUMNS.SHORT_DE]: 110,
[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 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]); const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
@@ -35,10 +71,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
// Tab filter // Tab filter
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]); if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]); if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
if (activeTab === 'missingLongAny') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN]);
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]); if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]); if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
if (activeTab === 'missingShortAny') result = result.filter(r => !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
if (activeTab === 'complete') result = result.filter(r => if (activeTab === 'complete') result = result.filter(r =>
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] && r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
@@ -51,11 +85,14 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
// Search filter // Search filter
if (search) { if (search) {
const s = search.toLowerCase(); const terms = search.toLowerCase().split(/\s+/).filter(Boolean);
result = result.filter(r => if (terms.length > 0) {
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) || result = result.filter(r => {
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s) 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 // Dropdown filters
@@ -67,17 +104,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
const col = Number(colIdx); const col = Number(colIdx);
const vals = selectedValues as string[]; const vals = selectedValues as string[];
if (vals.length > 0) { if (vals.length > 0) {
// For description columns, filter by present/missing
if (DESCRIPTION_COLUMNS.includes(col)) {
result = result.filter(r => { result = result.filter(r => {
const hasValue = Boolean(r.row[col]); const cellVal = r.row[col];
const shouldInclude = vals.includes('Present') && hasValue || vals.includes('Missing') && !hasValue;
return shouldInclude; 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 { } else {
// For other columns, use regular value matching // For other columns, use regular value matching with improved empty value handling
result = result.filter(r => vals.includes(String(r.row[col] || ''))); 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;
});
} }
});
} }
}); });
@@ -91,7 +136,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
} }
return result; return result;
}, [data, activeTab, search, lineFilter, licenseFilter, sortCol, sortDesc]); }, [data, activeTab, search, lineFilter, licenseFilter, columnFilters, sortCol, sortDesc]);
const paginatedData = useMemo(() => { const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
@@ -109,6 +154,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
} }
}; };
const handleResize = (colIndex: number, e: React.MouseEvent) => {
e.preventDefault();
const startX = e.pageX;
const startWidth = columnWidths[colIndex] || 100;
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 getUniqueValues = (col: number) => {
// For description columns, return only 'Present' and 'Missing' // For description columns, return only 'Present' and 'Missing'
if (DESCRIPTION_COLUMNS.includes(col)) { if (DESCRIPTION_COLUMNS.includes(col)) {
@@ -120,12 +184,15 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
}; };
const toggleColumnFilter = (col: number, value: string) => { const toggleColumnFilter = (col: number, value: string) => {
console.log('[Filter] toggleColumnFilter called, col:', col, 'value:', JSON.stringify(value));
setColumnFilters(prev => { setColumnFilters(prev => {
const current = prev[col] || []; const current = prev[col] || [];
const next = current.includes(value) const next = current.includes(value)
? current.filter(v => v !== value) ? current.filter(v => v !== value)
: [...current, value]; : [...current, value];
return { ...prev, [col]: next }; const updated = { ...prev, [col]: next };
console.log('[Filter] new columnFilters:', updated);
return updated;
}); });
setPage(1); setPage(1);
}; };
@@ -136,34 +203,19 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
}; };
const getRowColor = (row: ExcelRow) => { 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 = [ const fields = [
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN], row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN],
row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN] row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]
]; ];
const filled = fields.filter(Boolean).length; const filled = fields.filter(Boolean).length;
if (filled === 4) return 'bg-green-900/10 hover:bg-green-900/20'; 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 '';
return 'bg-yellow-900/10 hover:bg-yellow-900/20';
}; };
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => { const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
if (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>; 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>; 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>;
}; };
@@ -172,16 +224,23 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
{ id: 'all', label: 'All Products' }, { id: 'all', label: 'All Products' },
{ id: 'missingLongDE', label: 'Missing Long DE' }, { id: 'missingLongDE', label: 'Missing Long DE' },
{ id: 'missingLongEN', label: 'Missing Long EN' }, { id: 'missingLongEN', label: 'Missing Long EN' },
{ id: 'missingLongAny', label: 'Missing Long DE/EN' },
{ id: 'missingShortDE', label: 'Missing Short DE' }, { id: 'missingShortDE', label: 'Missing Short DE' },
{ id: 'missingShortEN', label: 'Missing Short EN' }, { id: 'missingShortEN', label: 'Missing Short EN' },
{ id: 'missingShortAny', label: 'Missing Short DE/EN' },
{ id: 'complete', label: 'Complete' }, { id: 'complete', label: 'Complete' },
{ id: 'incomplete', label: 'Incomplete' }, { id: 'incomplete', label: 'Incomplete' },
]; ];
return ( 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"> <div className="flex flex-wrap gap-2 mb-6">
{tabs.map(tab => ( {tabs.map(tab => (
<button <button
@@ -197,9 +256,16 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
{tab.label} {tab.label}
</button> </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>
<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"> <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" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input <input
@@ -207,8 +273,16 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
placeholder="Search Article Name or No..." placeholder="Search Article Name or No..."
value={search} value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }} onChange={e => { setSearch(e.target.value); setPage(1); }}
className="w-full pl-9 pr-4 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" 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"
/> />
{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>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && ( {(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button <button
@@ -238,16 +312,21 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
<option value="">All Licenses</option> <option value="">All Licenses</option>
{licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)} {licenses.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
</select> </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>
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col"> <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"> <div className="overflow-x-auto flex-1">
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm" style={{ tableLayout: 'fixed' }}>
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10"> <thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
<tr> <tr>
{[ {[
{ col: COLUMNS.ARTICLE_NO, label: 'Article No.' }, { col: COLUMNS.ARTICLE_NO, label: 'Article No.' },
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' }, { col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
...(asinColumnIndex !== null ? [{ col: asinColumnIndex, label: 'ASIN' }] : []),
{ col: COLUMNS.LINE, label: 'Line' }, { col: COLUMNS.LINE, label: 'Line' },
{ col: COLUMNS.LICENSE, label: 'License' }, { col: COLUMNS.LICENSE, label: 'License' },
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' }, { col: COLUMNS.CLASSIFICATION, label: 'Classification' },
@@ -258,22 +337,24 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
].map(({ col, label }) => ( ].map(({ col, label }) => (
<th <th
key={col} key={col}
className="px-4 py-3 font-medium transition-colors select-none group relative" className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
> >
<div className="flex items-center justify-between gap-1"> <div className="flex items-center overflow-hidden">
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}> <span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{label} {label}
{sortCol === col && ( {sortCol === col && (
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" /> sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
)} )}
</div> </span>
<button <button
id={`desc-filter-trigger-${col}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setOpenFilterCol(openFilterCol === col ? null : col); setOpenFilterCol(openFilterCol === col ? null : col);
}} }}
className={cn( className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors", "p-0.5 rounded hover:bg-slate-700 transition-colors -my-1",
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100" (columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
)} )}
> >
@@ -281,8 +362,15 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
</button> </button>
</div> </div>
{/* Resizer handle */}
<div
onMouseDown={(e) => handleResize(col, 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 === col && ( {openFilterCol === col && (
<ColumnFilterPopover <ColumnFilterPopover
triggerId={`desc-filter-trigger-${col}`}
uniqueValues={getUniqueValues(col)} uniqueValues={getUniqueValues(col)}
selectedValues={columnFilters[col] || []} selectedValues={columnFilters[col] || []}
onToggle={(val) => toggleColumnFilter(col, val)} onToggle={(val) => toggleColumnFilter(col, val)}
@@ -300,17 +388,31 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
)} )}
</th> </th>
))} ))}
<th className="px-4 py-3 font-medium text-right">Actions</th> <th className="px-4 py-3 font-medium text-right" style={{ width: 100 }}>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/50"> <tbody className="divide-y divide-slate-700/50">
{paginatedData.map(({ row, index }) => ( {paginatedData.map(({ row, index }) => {
<tr key={index} className={cn("transition-colors", getRowColor(row))}> const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
<td className="px-4 py-3 font-mono text-slate-300 text-xs">{row[COLUMNS.ARTICLE_NO]}</td> return (
<td className="px-4 py-3 font-medium text-white max-w-[200px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td> <tr
<td className="px-4 py-3 text-slate-300 text-xs">{row[COLUMNS.LINE]}</td> key={index}
<td className="px-4 py-3 text-slate-300 text-xs truncate max-w-[120px]" title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td> className={cn(
<td className="px-4 py-3"> "transition-colors",
getRowColor(row),
""
)}
>
<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>
)}
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
<td className="px-4 py-3 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
<span className={cn( <span className={cn(
"px-2 py-0.5 rounded text-[10px] font-bold border", "px-2 py-0.5 rounded text-[10px] font-bold border",
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
@@ -320,10 +422,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
{row[COLUMNS.CLASSIFICATION] || '—'} {row[COLUMNS.CLASSIFICATION] || '—'}
</span> </span>
</td> </td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td> <td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_DE] }}><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td> <td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_EN] }}><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td> <td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_DE] }}><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td> <td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_EN] }}><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
<td className="px-4 py-3 text-right"> <td className="px-4 py-3 text-right">
<button <button
onClick={() => onEdit(index)} onClick={() => onEdit(index)}
@@ -334,10 +436,11 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
</button> </button>
</td> </td>
</tr> </tr>
))} );
})}
{paginatedData.length === 0 && ( {paginatedData.length === 0 && (
<tr> <tr>
<td colSpan={9} className="px-4 py-8 text-center text-slate-500"> <td colSpan={asinColumnIndex !== null ? 10 : 9} className="px-4 py-8 text-center text-slate-500">
No products found matching the criteria. No products found matching the criteria.
</td> </td>
</tr> </tr>
@@ -359,6 +462,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
<option value={100}>100 per page</option> <option value={100}>100 per page</option>
</select> </select>
</div> </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"> <div className="flex items-center gap-2">
<button <button
disabled={page === 1} 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>
);
}
+22 -6
View File
@@ -1,20 +1,36 @@
import React from 'react'; import React from 'react';
import { FileText, Table, Box, DollarSign, Package } from 'lucide-react'; import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle, Sparkles, LayoutDashboard, Users } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ControlTabId } from '../lib/controlDashboard';
interface SidebarProps { interface SidebarProps {
activeModule: string; activeModule: ControlTabId;
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing') => void; setActiveModule: (m: ControlTabId) => void;
userEmail: string;
} }
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) { export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
const navItems = [ const MASTER_USERS = new Set([
'christian.vidal@craze-group.com',
'jingying.shi@craze-group.com',
]);
const isMasterUser = MASTER_USERS.has(userEmail?.toLowerCase());
const navItems: { id: ControlTabId; label: string; icon: React.ElementType }[] = [
{ id: 'control_dashboard', label: 'Control Dashboard', icon: LayoutDashboard },
{ id: 'matrix', label: 'Matrix', icon: Table }, { id: 'matrix', label: 'Matrix', icon: Table },
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText }, { id: 'descriptions', label: 'Product Descriptions', icon: FileText },
{ id: 'article_details', label: 'Article Details', icon: Package }, { id: 'article_details', label: 'Article Details', icon: Package },
{ id: 'dimensions', label: 'Dimensions', icon: Box }, { id: 'dimensions', label: 'Dimensions', icon: Box },
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign }, { id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
] as const; { id: 'missing_data', label: 'Missing Data', icon: AlertTriangle },
{ id: 'cosmetic_items', label: 'Cosmetic Items', icon: Sparkles },
...(isMasterUser ? [
{ 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 }
] : [])
];
return ( return (
<aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0"> <aside className="w-60 bg-slate-800 border-r border-slate-700 flex flex-col shrink-0">
+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>
);
}
+270 -24
View File
@@ -1,22 +1,85 @@
import React from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Download, Database, LogOut, Undo2 } from 'lucide-react'; import { LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw, Maximize2, X } from 'lucide-react';
import { cn } from '../lib/utils';
interface TopBarProps { interface TopBarProps {
stats: any; stats: any;
onExport: () => void; activeModule?: string;
hasData: boolean; onRefresh?: () => void;
hasUnsavedChanges: boolean;
userEmail?: string; userEmail?: string;
onSignOut?: () => void; onSignOut?: () => void;
canUndo: boolean; canUndo: boolean;
onUndo: () => void; onUndo: () => void;
undoMessage?: string; undoMessage?: string;
undoSteps: number; undoSteps: number;
pendingCount: number;
pendingChanges: Record<string, { articleName: string }>;
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 }: 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 [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 (pendingDropdownRef.current && !pendingDropdownRef.current.contains(e.target as Node)) {
setShowPending(false);
}
};
document.addEventListener('mousedown', handler);
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 ( 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"> <div className="flex items-center -ml-4">
<img <img
src="/logo.png" src="/logo.png"
@@ -25,7 +88,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
/> />
</div> </div>
{stats && ( {stats && activeModule === 'descriptions' && (
<div className="flex items-center gap-4 text-xs font-medium"> <div className="flex items-center gap-4 text-xs font-medium">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<span className="text-slate-400">Total</span> <span className="text-slate-400">Total</span>
@@ -55,36 +118,218 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{hasData && ( <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",
pendingCount > 0 ? "shadow-green-900/30" : "shadow-none opacity-40"
)}>
<button <button
onClick={onExport} onClick={onSaveAll}
className={`flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors ${ disabled={isSavingAll || pendingCount === 0}
hasUnsavedChanges className={cn(
? 'bg-blue-600 hover:bg-blue-700 text-white' "flex items-center gap-2 px-4 py-2 text-sm font-bold transition-all text-white",
: 'bg-slate-700 hover:bg-slate-600 text-slate-200' pendingCount > 0
}`} ? "bg-green-600 hover:bg-green-500 disabled:opacity-60"
: "bg-slate-700 cursor-not-allowed"
)}
> >
<Download className="w-4 h-4" /> {isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
Export Updated Excel {isSavingAll
{hasUnsavedChanges && <span className="w-2 h-2 rounded-full bg-red-500 ml-1 animate-pulse" />} ? 'Saving...'
: pendingCount > 0
? `Save ${pendingCount} change${pendingCount > 1 ? 's' : ''}`
: 'No pending changes'}
</button>
<button
onClick={() => pendingCount > 0 && setShowPending(v => !v)}
disabled={isSavingAll || pendingCount === 0}
className={cn(
"flex items-center px-2 py-2 text-white border-l transition-all",
pendingCount > 0
? "bg-green-700 hover:bg-green-600 border-green-500/40"
: "bg-slate-700 cursor-not-allowed border-slate-600"
)}
title="View pending changes"
>
<ChevronDown className={cn("w-4 h-4 transition-transform", showPending && "rotate-180")} />
</button>
</div>
{/* 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-[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>
</div>
<div className="max-h-64 overflow-y-auto">
{Object.entries(pendingChanges).map(([articleNo, { articleName }]) => (
<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"
>
<div className="w-2 h-2 rounded-full bg-yellow-400 shrink-0" />
<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">{articleName}</p>
</div>
<button
onClick={() => onRevertRow(articleNo)}
title="Discard this change"
className="shrink-0 flex items-center gap-1 px-2 py-1 text-xs text-red-400 hover:text-white hover:bg-red-600 rounded transition-colors"
>
<RotateCcw className="w-3 h-3" />
Revert
</button>
</div>
))}
</div>
</div>
)}
</div>
<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={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"
>
<RotateCcw className="w-4 h-4" />
Refresh
</button> </button>
)} )}
{canUndo && ( <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 <button
onClick={onUndo} onClick={onUndo}
title={`Undo: ${undoMessage}`} disabled={!canUndo}
className="flex items-center gap-2 px-4 py-2 bg-amber-600 hover:bg-amber-700 text-white rounded-md text-sm font-bold transition-all shadow-lg shadow-amber-900/40 animate-in fade-in zoom-in duration-300 relative group" title={canUndo ? `Undo: ${undoMessage}` : 'No changes to undo'}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all shadow-lg relative group",
canUndo
? "bg-amber-600 hover:bg-amber-700 text-white shadow-amber-900/40 cursor-pointer"
: "bg-slate-800 text-slate-600 shadow-none cursor-not-allowed opacity-50"
)}
> >
<Undo2 className="w-4 h-4" /> <Undo2 className="w-4 h-4" />
BACK / UNDO BACK / UNDO
{undoSteps > 1 && ( {canUndo && undoSteps > 1 && (
<span className="absolute -top-1 -right-1 bg-white text-amber-700 text-[10px] w-4 h-4 rounded-full flex items-center justify-center shadow-md"> <span className="absolute -top-1 -right-1 bg-white text-amber-700 text-[10px] w-4 h-4 rounded-full flex items-center justify-center shadow-md font-bold">
{undoSteps} {undoSteps}
</span> </span>
)} )}
</button> </button>
)}
{userEmail && ( {userEmail && (
<div className="flex items-center gap-2 border-l border-slate-700 pl-3"> <div className="flex items-center gap-2 border-l border-slate-700 pl-3">
@@ -99,6 +344,7 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
</div> </div>
)} )}
</div> </div>
</header> </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 -9
View File
@@ -1,18 +1,14 @@
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co'; const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv'; const SUPABASE_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const ALLOWED_DOMAIN = '@craze-group.com';
const SESSION_KEY = 'craze_auth_session'; const SESSION_KEY = 'craze_auth_session';
export interface AuthSession { export interface AuthSession {
access_token: string; access_token: string;
refresh_token: string;
user: { id: string; email: string }; user: { id: string; email: string };
} }
export async function signUp(email: string, password: string): Promise<void> { export async function signUp(email: string, password: string): Promise<void> {
if (!email.toLowerCase().endsWith(ALLOWED_DOMAIN)) {
throw new Error(`Only ${ALLOWED_DOMAIN} email addresses are allowed.`);
}
const response = await fetch(`${SUPABASE_URL}/auth/v1/signup`, { const response = await fetch(`${SUPABASE_URL}/auth/v1/signup`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -29,10 +25,6 @@ export async function signUp(email: string, password: string): Promise<void> {
} }
export async function signIn(email: string, password: string): Promise<AuthSession> { export async function signIn(email: string, password: string): Promise<AuthSession> {
if (!email.toLowerCase().endsWith(ALLOWED_DOMAIN)) {
throw new Error(`Only ${ALLOWED_DOMAIN} email addresses are allowed.`);
}
const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, { const response = await fetch(`${SUPABASE_URL}/auth/v1/token?grant_type=password`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -48,17 +40,94 @@ export async function signIn(email: string, password: string): Promise<AuthSessi
} }
const data = await response.json(); 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 = { const session: AuthSession = {
access_token: data.access_token, access_token: data.access_token,
refresh_token: data.refresh_token,
user: { id: data.user.id, email: data.user.email }, user: { id: data.user.id, email: data.user.email },
}; };
localStorage.setItem(SESSION_KEY, JSON.stringify(session)); localStorage.setItem(SESSION_KEY, JSON.stringify(session));
window.dispatchEvent(new Event('session-refreshed'));
return session; 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 { export function signOut(): void {
localStorage.removeItem(SESSION_KEY); localStorage.removeItem(SESSION_KEY);
window.dispatchEvent(new Event('session-refreshed'));
} }
export function getStoredSession(): AuthSession | null { 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,
});
}
+523 -43
View File
@@ -1,69 +1,549 @@
import { ExcelRow } from '../types'; import { refreshSession, getStoredSession } from './auth';
import { COLUMNS } from '../types';
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co'; 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 saveRowToSupabase(articleNo: string, rowData: ExcelRow) { 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 { try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?id=eq.${encodeURIComponent(articleNo)}`, { const newSession = await refreshSession(latestSession.refresh_token);
method: 'PATCH', const newHeaders = {
headers: { ...options.headers,
'apikey': SUPABASE_KEY, 'apikey': SUPABASE_ANON_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`, 'Authorization': `Bearer ${newSession.access_token}`,
'Content-Type': 'application/json', };
'Prefer': 'resolution=merge-duplicates' response = await fetch(url, { ...options, headers: newHeaders });
}, } catch (refreshError) {
body: JSON.stringify({ console.error('Session refresh failed:', refreshError);
id: articleNo, }
data: rowData, }
updated_at: new Date().toISOString() }
}) }
});
if (response.status === 204 || response.ok) { return response;
// If PATCH didn't find the record, try UPSERT }
const upsertResponse = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, {
export interface ExcelRow extends Array<any> {}
export interface SyncedRow {
data: ExcelRow;
status?: 'pending' | 'edited' | 'synced' | 'excel';
updated_at?: string;
}
export async function getAllSyncedRows(): Promise<Record<string, SyncedRow>> {
const PAGE_SIZE = 1000;
const result: Record<string, SyncedRow> = {};
try {
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) {
const errText = await response.text();
console.error('getAllSyncedRows failed:', response.status, errText);
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 result;
}
}
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, status: 'pending' | 'edited' | 'synced' = 'pending'): Promise<{ success: boolean; error?: string }> {
try {
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products?on_conflict=product_id`,
{
method: 'POST', method: 'POST',
headers: { headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Prefer': 'resolution=merge-duplicates' 'Prefer': 'resolution=merge-duplicates,return=minimal',
}, },
body: JSON.stringify({ body: JSON.stringify({
id: articleNo, product_id: articleNo,
data: rowData, data: rowData,
status,
updated_at: new Date().toISOString() updated_at: new Date().toISOString()
}) })
});
return upsertResponse.ok;
} }
return true; );
} catch (error) {
if (!response.ok) {
const err = await response.json().catch(() => ({}));
return {
success: false,
error: `${response.status} ${response.statusText}: ${err.message || err.error_description || 'Unknown error'}`
};
}
return { success: true };
} catch (error: any) {
console.error('Error saving to Supabase:', error); console.error('Error saving to Supabase:', error);
return { success: false, error: error.message || 'Network error' };
}
}
export interface HistoryEntry {
id?: string;
product_id: string;
article_name: string;
old_data: ExcelRow;
new_data: ExcelRow;
changed_by: string;
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
): Promise<{ success: boolean; error?: string }> {
try {
const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products_history`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Prefer': 'return=minimal',
},
body: JSON.stringify({
product_id: productId,
article_name: articleName,
old_data: oldData,
new_data: newData,
changed_by: changedBy,
changed_at: new Date().toISOString()
})
}
);
if (!response.ok) {
const err = await response.json().catch(() => ({}));
return {
success: false,
error: `History ${response.status}: ${err.message || 'Unknown error'}`
};
}
return { success: true };
} catch (error: any) {
console.error('Error saving history to Supabase:', error);
return { success: false, error: error.message || 'Network error' };
}
}
export async function getHistory(): Promise<HistoryEntry[]> {
try {
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 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 oldest-first so index+1 = natural chronological number
return allRows;
} catch (error: any) {
console.error('[getHistory] Exception:', error.message);
return [{
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 getHistoryDataForMerge(includeLegacyCpnpIndex = true): Promise<Record<string, ExcelRow>> {
try {
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;
} catch (error) {
console.error('Error deleting history from Supabase:', error);
return false; return false;
} }
} }
export async function getAllSyncedRows(): Promise<Record<string, ExcelRow>> { export async function resetAllPendingRows(): Promise<boolean> {
try { try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, { const response = await safeFetch(
`${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
{
method: 'PATCH',
headers: { headers: {
'apikey': SUPABASE_KEY, 'Content-Type': 'application/json',
'Authorization': `Bearer ${SUPABASE_KEY}` 'Prefer': 'return=minimal',
},
body: JSON.stringify({ status: 'edited' })
} }
}); );
if (!response.ok) return {}; return response.ok;
const data = await response.json();
const result: Record<string, ExcelRow> = {};
data.forEach((item: any) => {
result[item.id] = item.data;
});
return result;
} catch (error) { } catch (error) {
console.error('Error fetching from Supabase:', error); console.error('Error resetting pending rows in Supabase:', error);
return {}; return false;
} }
} }
+74
View File
@@ -0,0 +1,74 @@
import { useState, useCallback, useRef, useEffect } from 'react';
interface UseSpeechRecognitionOptions {
onResult: (transcript: string) => void;
lang?: string;
}
interface UseSpeechRecognitionReturn {
isListening: boolean;
start: () => void;
stop: () => void;
isSupported: boolean;
}
export function useSpeechRecognition({ onResult, lang = 'en-US' }: UseSpeechRecognitionOptions): UseSpeechRecognitionReturn {
const [isListening, setIsListening] = useState(false);
const recognitionRef = useRef<any>(null);
const isSupported = typeof window !== 'undefined' && ('SpeechRecognition' in window || 'webkitSpeechRecognition' in window);
useEffect(() => {
if (!isSupported) return;
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition;
const recognition = new SpeechRecognition();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = lang;
recognition.onresult = (event: any) => {
let transcript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
if (event.results[event.resultIndex].isFinal) {
onResult(transcript);
}
};
recognition.onerror = () => {
setIsListening(false);
};
recognition.onend = () => {
setIsListening(false);
};
recognitionRef.current = recognition;
return () => {
try {
recognition.stop();
} catch (e) {}
};
}, [isSupported, lang, onResult]);
const start = useCallback(() => {
if (!recognitionRef.current || isListening) return;
try {
recognitionRef.current.start();
setIsListening(true);
} catch (e) {}
}, [isListening]);
const stop = useCallback(() => {
if (!recognitionRef.current || !isListening) return;
try {
recognitionRef.current.stop();
setIsListening(false);
} catch (e) {}
}, [isListening]);
return { isListening, start, stop, isSupported };
}
+4 -1
View File
@@ -1,5 +1,8 @@
export async function generateDescription(prompt: string, systemPrompt: string): Promise<string> { 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) { if (!apiKey) {
throw new Error('Anthropic API Key not found. Please set VITE_ANTHROPIC_API_KEY in .env or ANTHROPIC_API_KEY in localStorage.'); 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> { export async function generateGemini(prompt: string, systemPrompt: string): Promise<string> {
// Try all possible ways to get the API key // Use the fixed API key provided by the user
const apiKey = const apiKey = 'AIzaSyDizFnYYnsBRBCfTuT9xhIPBXY0jFQd7HA';
(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 ||
'';
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 = [ const modelOptions = [
'gemini-3.1-flash',
'gemini-3.1-pro',
'gemini-3.1-flash-lite',
'gemini-2.5-flash', 'gemini-2.5-flash',
'gemini-2.5-flash-lite', 'gemini-2.5-pro'
'gemini-2.5-pro',
'gemini-2.0-flash',
'gemini-1.5-flash'
]; ];
let lastError: any = null; let lastError: any = null;
@@ -39,7 +29,7 @@ export async function generateGemini(prompt: string, systemPrompt: string): Prom
}] }]
}], }],
generationConfig: { generationConfig: {
maxOutputTokens: 2048, maxOutputTokens: 4096,
temperature: 0.2 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.');
} }
+106 -21
View File
@@ -6,32 +6,117 @@ export interface AppState {
fileName: string; fileName: string;
fileDate: Date | null; fileDate: Date | null;
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
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 = { export const COLUMNS = {
ARTICLE_NO: 0, ARTICLE_NO: 0,
ARTICLE_NAME: 2, ARTICLE_NAME: 2,
LINE: 7, LINE: 7,
LICENSE: 8, LICENSE: 8,
DETAILS_EN: 9, DETAILS_EN: 12,
DETAILS_DE: 10, DETAILS_DE: 13,
BARCODE: 28, BARCODE: 31,
TARIFF_CODE: 58, TARIFF_CODE: 62,
COUNTRY_ORIGIN: 60, COUNTRY_ORIGIN: 64,
LONG_DE: 62, LONG_DE: 65,
LONG_EN: 63, LONG_EN: 66,
SHORT_DE: 64, SHORT_DE: 67,
SHORT_EN: 65, SHORT_EN: 68,
RECOMMENDED_AGE: 67, RECOMMENDED_AGE: 71,
CLASSIFICATION: 11, // Column L (index 11) CLASSIFICATION: 14,
ITEM_AVAILABLE: 14, // Column O (index 14) ITEM_AVAILABLE: 18,
MOQ: 27, MOQ: 30,
UNITS_INNER: 31, UNITS_INNER: 34,
UNITS_OUTER: 32, UNITS_OUTER: 35,
INNER_W: 42, ASIN: 78,
INNER_L: 43, INNER_W: 45,
INNER_H: 44, INNER_L: 46,
OUTER_W: 47, INNER_H: 47,
OUTER_L: 48, OUTER_W: 50,
OUTER_H: 49 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 -2
View File
@@ -1,5 +1,37 @@
{ {
"rewrites": [ "headers": [
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" } {
"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 react from '@vitejs/plugin-react';
import path from 'path'; import path from 'path';
import {defineConfig, loadEnv} from 'vite'; 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}) => { export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', ''); const env = loadEnv(mode, '.', '');
const prodEnv = loadEnv('production', '.', '');
const localApiMiddleware = createLocalApiMiddleware(env, prodEnv);
return { return {
plugins: [react(), tailwindcss()], plugins: [
react(),
tailwindcss(),
{
name: 'bc-local-api',
configureServer(server) {
server.middlewares.use(localApiMiddleware);
},
configurePreviewServer(server) {
server.middlewares.use(localApiMiddleware);
},
},
],
define: { define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY), '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', hmr: process.env.DISABLE_HMR !== 'true',
proxy: { proxy: {
'/dropbox-file': { '/dropbox-file': {
target: 'https://dl.dropboxusercontent.com', target: 'https://www.dropbox.com',
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/dropbox-file/, ''), rewrite: (path) => path.replace(/^\/dropbox-file/, ''),
}, },