Author SHA1 Message Date
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
Christian Vidal WolfandQwen-Coder 20a0c2d786 feat: restrict description column filters to Present/Missing only
Long DE, Long EN, Short DE, and Short EN columns now only show
Present/Missing filter options instead of all unique values

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-08 17:11:27 +02:00
Christian Vidal Wolf 976f38eef8 feat: final Select All styling and sync to main 2026-04-08 17:03:00 +02:00
Christian Vidal Wolf 29f3a469fe test: change Clear to BORRAR to verify user view 2026-04-08 17:02:35 +02:00
Christian Vidal Wolf f3f9f1547f style: move Select All to bottom bar of ColumnFilterPopover 2026-04-08 16:59:39 +02:00
Christian Vidal Wolf 89aa222470 feat: standardize ColumnFilterPopover and add SELECT ALL to all views/tabs 2026-04-08 16:56:43 +02:00
Christian Vidal Wolf 786c66fd87 feat: add Select All to column filters and refine views 2026-04-08 16:50:53 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 71af4e1915 feat: add Classification column and Excel-style column filters to Pricing & Units tab
- Added Classification column next to Line with color-coded badges (Core=blue, OOC=amber)
- Added Excel-style filter icons on Article Name (text search), Line, and Classification headers
- Filter icons appear on hover; turn blue when active
- Multi-select popover for Line and Classification; free-text popover for Article Name
- Filters work additively alongside the existing global search bar
- ArticleDetails: removed Tariff/Barcode/Origin columns and tab to simplify the view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:37:40 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 04d198ea59 feat: add Excel-like column filters and License/Classification columns to Product Descriptions
- Added per-column filter popover (Excel-style) with search, multi-select checkboxes, and clear button
- Filter icon appears on hover for each column header; turns blue when active
- "Clear All Column Filters" button appears in toolbar when any column filter is active
- Added License and Classification columns to the table with OOC badge styling

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:18:24 +02:00
Christian Vidal WolfandClaude Sonnet 4.6 8fa001c6ed feat: short description AI targeting 30% length + checkbox sync for near-duplicate dimensions
- EditPanel: short description prompts now target ~30% of the long description character count, preserving key highlights as flowing prose
- DimensionsView: near-duplicate clusters expanded view replaced with a flat checkbox list (SKU + name + current dims per row), a target-dimension dropdown, and a "Sync N selected" button that unifies inner dimensions across selected products via ConfirmModal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 16:11:37 +02:00
Christian Vidal Wolf 24042a6aed feat: reorganize tabs, add Article Details, and enhance filters/search 2026-04-08 16:10:14 +02:00
Christian Vidal Wolf 935cd5b510 feat: add search functionality to Matrix View 2026-04-07 13:40:50 +02:00
Christian Vidal Wolf ec1b506341 feat: include article details (DE/EN) in completeness check and make them editable 2026-04-07 13:38:21 +02:00
27 changed files with 3757 additions and 547 deletions
+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 *)"
]
}
}
+15 -81
View File
@@ -15,7 +15,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 +27,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 +42,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 +71,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)
│ └── ... ├── types.ts # TypeScript types and constants
├── lib/ # Utilities and helpers ├── App.tsx # Main application
│ ├── utils.ts # cn(), helpers └── main.tsx # Entry point
│ ├── auth.ts # Authentication
│ └── supabase.ts # Database operations
├── services/ # External API integrations
│ ├── gemini.ts
│ └── anthropic.ts
├── types.ts # TypeScript types and constants
├── App.tsx # Main application
└── 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); return new Date(excelEpoch.getTime() + val * 86400000).toLocaleDateString('en-GB');
const date = new Date(excelEpoch.getTime() + val * 86400000);
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`
+59 -7
View File
@@ -1,16 +1,67 @@
// Vercel serverless function — proxies the Dropbox file server-side (no CORS) 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;
async function getAccessToken() {
const response = await fetch('https://api.dropboxapi.com/oauth2/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: DROPBOX_REFRESH_TOKEN,
client_id: DROPBOX_APP_KEY,
client_secret: DROPBOX_APP_SECRET,
})
});
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 = let accessToken;
'https://dl.dropboxusercontent.com/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx' + try {
'?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1'; accessToken = await getAccessToken();
} catch (err) {
console.error('Token refresh error:', err);
return res.status(500).json({ error: err.message });
}
if (req.method === 'GET' && req.query.info === '1') {
try {
const fileInfo = await fetch('https://api.dropboxapi.com/2/files/get_metadata', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
});
const data = await fileInfo.json();
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
return res.json({ rev: data.rev, size: data.size, server_modified: data.server_modified });
} catch (err) {
return res.status(500).json({ error: err.message });
}
}
try { try {
const upstream = await fetch(fileUrl, { const upstream = await fetch('https://content.dropboxapi.com/2/files/download', {
headers: { 'User-Agent': 'Mozilla/5.0' }, method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'Dropbox-API-Arg': JSON.stringify({ path: '/CRAZE GmbH/Sales Reports/Data Matrix.xlsx' })
}
}); });
if (!upstream.ok) { if (!upstream.ok) {
return res.status(upstream.status).send(`Dropbox error: ${upstream.status}`); const errText = await upstream.text();
console.error('Dropbox API error:', upstream.status, errText);
return res.status(upstream.status).send('Dropbox error: ' + errText);
} }
const buffer = await upstream.arrayBuffer(); const buffer = await upstream.arrayBuffer();
@@ -18,6 +69,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) {
console.error('Dropbox proxy error:', err);
res.status(500).send('Proxy error: ' + err.message); res.status(500).send('Proxy error: ' + err.message);
} }
} }
+58
View File
@@ -0,0 +1,58 @@
import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = process.env.SUPABASE_URL || 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_KEY || 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY);
export default async function handler(req, res) {
try {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const { rows, fileMeta } = req.body;
if (!rows || !Array.isArray(rows)) {
return res.status(400).json({ error: 'Missing rows data' });
}
const articleNoIdx = 0;
const productsToUpsert = [];
for (const row of rows) {
const productId = String(row[articleNoIdx]);
if (productId && productId.trim() !== '') {
productsToUpsert.push({
product_id: productId,
data: row,
status: 'synced',
updated_at: new Date().toISOString()
});
}
}
console.log('Upserting', productsToUpsert.length, 'products to Supabase...');
const { error } = await supabase
.from('products')
.upsert(productsToUpsert, {
onConflict: 'product_id',
ignoreDuplicates: true
});
if (error) {
console.error('Supabase upsert error:', error);
return res.status(500).json({ error: error.message, detail: 'Failed to upsert products' });
}
return res.json({
success: true,
syncedCount: productsToUpsert.length
});
} catch (err) {
console.error('Handler error:', err);
res.status(500).json({ error: err.message });
}
}
+254
View File
@@ -9,10 +9,13 @@
"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",
"buzz": "^2.0.0",
"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 +1178,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 +1638,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 +1702,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 +1768,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",
@@ -1816,6 +1931,12 @@
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/buzz": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/buzz/-/buzz-2.0.0.tgz",
"integrity": "sha512-eYeTETPJp7hWUX7j3o8iJNR8VLaaWRuOYYPWTSQqA5pIxZ2g3ZJUdyjfNZnGvr85IDhfr4ONQQflGp8+MC034A==",
"license": "MIT"
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1905,6 +2026,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 +2118,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 +2167,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 +2289,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 +2510,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 +2772,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 +2832,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 +3941,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 +4605,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",
+3
View File
@@ -12,10 +12,13 @@
}, },
"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",
"buzz": "^2.0.0",
"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",
+338 -101
View File
@@ -6,95 +6,164 @@ import { TopBar } from './components/TopBar';
import { ProductDescriptions } from './components/ProductDescriptions'; import { ProductDescriptions } from './components/ProductDescriptions';
import { MatrixView } from './components/MatrixView'; import { MatrixView } from './components/MatrixView';
import { EditPanel } from './components/EditPanel'; import { EditPanel } from './components/EditPanel';
import { getAllSyncedRows, saveRowToSupabase } from './lib/supabase'; import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry, deleteHistoryEntry } from './lib/supabase';
import { getStoredSession, signOut, type AuthSession } from './lib/auth'; import { getStoredSession, signOut, type AuthSession } from './lib/auth';
import { LoginPage } from './components/LoginPage'; import { LoginPage } from './components/LoginPage';
import { DimensionsView } from './components/DimensionsView'; import { DimensionsView } from './components/DimensionsView';
import { PricingView } from './components/PricingView'; import { PricingView } from './components/PricingView';
import { ArticleDetails } from './components/ArticleDetails';
import { HistoryView } from './components/HistoryView';
import { UndoToast } from './components/UndoToast'; import { UndoToast } from './components/UndoToast';
import { PendingValidationView } from './components/PendingValidationView';
import { MissingDataView } from './components/MissingDataView';
export default function App() { export default function App() {
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession()); const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
const handleSignOut = () => {
signOut();
setSession(null);
};
if (!session) {
return <LoginPage onLogin={() => setSession(getStoredSession())} />;
}
const [appState, setAppState] = useState<AppState>({ const [appState, setAppState] = useState<AppState>({
headers: [], headers: [],
data: [], data: [],
fileName: '', fileName: '',
fileDate: null, fileDate: null,
hasUnsavedChanges: false hasUnsavedChanges: false,
asinColumnIndex: null
}); });
const [activeModule, setActiveModule] = useState<'descriptions' | 'matrix' | 'dimensions' | 'pricing'>('descriptions'); const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data'>('descriptions');
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]); const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null); const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [isLoadingDefault, setIsLoadingDefault] = useState(true); const [isLoadingDefault, setIsLoadingDefault] = useState(true);
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null); const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
const [isSavingAll, setIsSavingAll] = useState(false);
useEffect(() => {
console.log('[App] session changed:', session ? 'logged in' : 'logged out');
}, [session]);
const handleSignOut = () => {
signOut();
setSession(null);
};
useEffect(() => { useEffect(() => {
const loadDefaultData = async () => { const loadDefaultData = async () => {
// In dev: Vite proxy handles /dropbox-file (see vite.config.ts)
// In prod: Vercel serverless function at /api/dropbox-proxy handles it
const fileUrl = import.meta.env.DEV
? '/dropbox-file/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1'
: '/api/dropbox-proxy';
setIsLoadingDefault(true); setIsLoadingDefault(true);
setDefaultLoadError(null); setDefaultLoadError(null);
try { try {
console.log('Fetching Data-Matrix.xlsx via Vite proxy...'); const isDev = import.meta.env.DEV;
const response = await fetch(fileUrl);
let rows: any[][];
if (!response.ok) { let allData: any[][];
throw new Error(`HTTP ${response.status}: ${response.statusText}`); let fileMeta = { rev: '', size: 0 };
if (isDev) {
const fileUrl = '/dropbox-file/scl/fi/nkyabkq2jdwfudof2ovrl/Data-Matrix.xlsx?rlkey=1cz5fuabwipqqivihii7sybw0&st=io0g4wvb&dl=1';
console.log('Fetching Data-Matrix.xlsx from Dropbox...');
const response = await fetch(fileUrl);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength < 100) throw new Error('File too small');
const wb = XLSX.read(arrayBuffer, { type: 'array' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
rows = allData.slice(1);
fileMeta = { rev: 'dev', size: arrayBuffer.byteLength };
} else {
console.log('Fetching file info from Dropbox...');
const infoRes = await fetch('/api/dropbox-proxy?info=1');
if (infoRes.ok) {
fileMeta = await infoRes.json();
console.log('File meta:', fileMeta);
}
console.log('Fetching Data-Matrix.xlsx from proxy...');
const response = await fetch('/api/dropbox-proxy');
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const arrayBuffer = await response.arrayBuffer();
if (arrayBuffer.byteLength < 100) throw new Error('File too small');
const wb = XLSX.read(arrayBuffer, { type: 'array' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
allData = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
rows = allData.slice(1);
} }
const arrayBuffer = await response.arrayBuffer(); if (rows.length > 0) {
if (arrayBuffer.byteLength < 100) { const headers = allData.slice(0, 1)[0];
throw new Error('File too small — possibly empty or error response');
} console.log('Syncing Excel data to Supabase...');
const syncRes = await fetch('/api/dropbox-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rows, fileMeta })
});
if (syncRes.ok) {
const syncResult = await syncRes.json();
console.log('Supabase sync result:', syncResult);
} else {
const errorText = await syncRes.text();
console.warn('Supabase sync failed:', syncRes.status, errorText);
}
console.log('Fetching synced data from Supabase...');
const syncedData = await getAllSyncedRows(session?.access_token);
const editableColumns = new Set([
COLUMNS.CLASSIFICATION,
COLUMNS.LONG_DE, COLUMNS.LONG_EN,
COLUMNS.SHORT_DE, COLUMNS.SHORT_EN,
COLUMNS.DETAILS_DE, COLUMNS.DETAILS_EN,
COLUMNS.INNER_L, COLUMNS.INNER_W, COLUMNS.INNER_H,
COLUMNS.OUTER_L, COLUMNS.OUTER_W, COLUMNS.OUTER_H,
COLUMNS.UNITS_OUTER, COLUMNS.MOQ,
COLUMNS.VERIFIED_DIMS
]);
headers.forEach((h: any, i: number) => {
const hl = String(h || '').toLowerCase();
if (hl.includes('srp') || hl.includes('uvp') || hl.includes('40') || hl.includes('price')) {
editableColumns.add(i);
}
});
const wb = XLSX.read(arrayBuffer, { type: 'array' });
const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname];
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
if (data.length > 0) {
const rawHeaders = data[0];
const rawRows = data.slice(1);
console.log('Applying Supabase overrides...');
const syncedData = await getAllSyncedRows();
const articleNoIdx = COLUMNS.ARTICLE_NO; const articleNoIdx = COLUMNS.ARTICLE_NO;
const processedRows = rows.map(row => {
const processedRows = rawRows.map(row => {
const articleNo = String(row[articleNoIdx]); const articleNo = String(row[articleNoIdx]);
const finalRow = syncedData[articleNo] || row; const synced = syncedData[articleNo];
// Format numeric/price fields to 2 decimal places const finalRow = [...row];
return finalRow.map((val, idx) => { if (synced) {
// Smart Merge: Only overlay fields we logically "own" via this app's editors
editableColumns.forEach(idx => {
if (synced.data[idx] !== undefined && synced.data[idx] !== null) {
finalRow[idx] = synced.data[idx];
}
});
if (synced.status === 'pending') {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
}
}
return finalRow.map((val: any, idx: number) => {
if (val === undefined || val === null || val === '') return val; if (val === undefined || val === null || val === '') return val;
const header = (rawHeaders[idx] || '').toLowerCase(); const header = (headers[idx] || '').toLowerCase();
// Skip Article No, Barcodes, and other code-like fields if ((header.includes('id') || header.includes('no') || header.includes('code') ||
// But allow if it's a weight/measure column (e.g. Article NW (kg)) header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
if ((header.includes('id') || header.includes('no') || header.includes('code') || !(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
return val; return val;
} }
const formatKeywords = ['price', 'eur', 'cost', 'msrp', 'net', 'gross', 'netto', 'brutto', 'pp', 'pph', 'uvp', 'vpe', 'stk', 'nw', 'gw', 'weight', 'kg']; const formatKeywords = ['price', 'eur', 'cost', 'msrp', 'net', 'gross', 'netto', 'brutto', 'pp', 'pph', 'uvp', 'vpe', 'stk', 'nw', 'gw', 'weight', 'kg'];
const shouldFormat = formatKeywords.some(kw => header.includes(kw)); const shouldFormat = formatKeywords.some(kw => header.includes(kw));
if (typeof val === 'number') { if (typeof val === 'number') {
return Number(val.toFixed(2)); return Number(val.toFixed(2));
} }
@@ -109,13 +178,18 @@ export default function App() {
return val; return val;
}); });
}); });
const asinIdx = (headers as string[]).findIndex((h: string) =>
String(h).toLowerCase().trim() === 'asin'
);
setAppState({ setAppState({
headers: rawHeaders, headers: headers,
data: processedRows, data: processedRows,
fileName: 'Data-Matrix.xlsx (Cloud Sync)', fileName: 'Data-Matrix.xlsx (Cloud Sync)',
fileDate: new Date(), fileDate: new Date(),
hasUnsavedChanges: false hasUnsavedChanges: false,
asinColumnIndex: asinIdx !== -1 ? asinIdx : null
}); });
setActiveModule('descriptions'); setActiveModule('descriptions');
} }
@@ -127,8 +201,8 @@ export default function App() {
} }
}; };
loadDefaultData(); if (session) loadDefaultData();
}, []); }, [session]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -148,18 +222,18 @@ export default function App() {
const wsname = wb.SheetNames[0]; const wsname = wb.SheetNames[0];
const ws = wb.Sheets[wsname]; const ws = wb.Sheets[wsname];
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 }); const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
if (data.length > 0) { if (data.length > 0) {
const rawHeaders = data[0]; const headers = data[0];
const rawRows = data.slice(1); const rawRows = data.slice(1);
const processedRows = rawRows.map(row => { const processedRows = rawRows.map(row => {
return row.map((val, idx) => { return row.map((val, idx) => {
if (val === undefined || val === null || val === '') return val; if (val === undefined || val === null || val === '') return val;
const header = (rawHeaders[idx] || '').toLowerCase(); const header = (headers[idx] || '').toLowerCase();
if (header.includes('id') || header.includes('no') || header.includes('code') || if (header.includes('id') || header.includes('no') || header.includes('code') ||
header.includes('art.') || header.includes('barcode') || header.includes('article')) { header.includes('art.') || header.includes('barcode') || header.includes('article')) {
// But allow if it's a weight/measure column (e.g. Article NW (kg)) // But allow if it's a weight/measure column (e.g. Article NW (kg))
if (!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) { if (!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
return val; return val;
@@ -182,7 +256,7 @@ export default function App() {
if (typeof val === 'number') { if (typeof val === 'number') {
return Number(val.toFixed(2)); return Number(val.toFixed(2));
} }
if (typeof val === 'string') { if (typeof val === 'string') {
const normalized = val.trim().replace(',', '.'); const normalized = val.trim().replace(',', '.');
const num = parseFloat(normalized); const num = parseFloat(normalized);
@@ -195,11 +269,12 @@ export default function App() {
}); });
setAppState({ setAppState({
headers: rawHeaders, headers: headers,
data: processedRows, data: processedRows,
fileName: file.name, fileName: file.name,
fileDate: new Date(), fileDate: new Date(),
hasUnsavedChanges: false hasUnsavedChanges: false,
asinColumnIndex: null
}); });
setActiveModule('descriptions'); setActiveModule('descriptions');
} }
@@ -209,42 +284,122 @@ export default function App() {
const handleExport = () => { const handleExport = () => {
if (appState.data.length === 0) return; if (appState.data.length === 0) return;
const wsData = [appState.headers, ...appState.data]; const wsData = [
appState.headers,
...appState.data.map(row => {
const copy = [...row];
delete copy[COLUMNS.VERIFIED_DIMS];
return copy.slice(0, appState.headers.length);
})
];
const ws = XLSX.utils.aoa_to_sheet(wsData); const ws = XLSX.utils.aoa_to_sheet(wsData);
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Products'); XLSX.utils.book_append_sheet(wb, ws, 'Products');
const dateStr = new Date().toISOString().split('T')[0]; const dateStr = new Date().toISOString().split('T')[0];
XLSX.writeFile(wb, `CRAZE_Products_Updated_${dateStr}.xlsx`); XLSX.writeFile(wb, `CRAZE_Products_Updated_${dateStr}.xlsx`);
// 3. Post-export: Reset pending statuses in Supabase
console.log('Resetting pending statuses in Supabase...');
resetAllPendingRows(session?.access_token).then(success => {
if (success) {
console.log('Successfully reset all pending statuses');
setRowStatuses({}); // Clear local statuses
}
});
setAppState(prev => ({ ...prev, hasUnsavedChanges: false })); setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
}; };
const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow) => { const handleSaveRow = (rowIndex: number, updatedRow: ExcelRow) => {
// 1. Update UI state const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
const originalData = appState.data[rowIndex]; // Capture before update
setAppState(prev => { setAppState(prev => {
const newData = [...prev.data]; const newData = [...prev.data];
newData[rowIndex] = updatedRow; newData[rowIndex] = updatedRow;
return { return { ...prev, data: newData, hasUnsavedChanges: true };
...prev,
data: newData,
hasUnsavedChanges: true
};
}); });
setPendingRows(prev => ({
...prev,
[articleNo]: {
rowIndex,
// Keep the very first originalData if already pending (re-edit case)
originalData: prev[articleNo]?.originalData ?? originalData,
newData: updatedRow,
articleName: String(updatedRow[COLUMNS.ARTICLE_NAME] || articleNo),
}
}));
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
setEditingRowIndex(null); setEditingRowIndex(null);
};
// 2. Persist to Supabase const handleRevertRow = (articleNo: string) => {
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]); const pending = pendingRows[articleNo];
console.log(`Saving article ${articleNo} to Supabase...`); if (!pending) return;
const success = await saveRowToSupabase(articleNo, updatedRow); setAppState(prev => {
const newData = [...prev.data];
newData[pending.rowIndex] = pending.originalData;
const stillPending = Object.keys(pendingRows).length > 0;
return { ...prev, data: newData, hasUnsavedChanges: stillPending };
});
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
setRowStatuses(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
};
const handleSaveAll = async () => {
console.log('[handleSaveAll] Starting save, pendingRows:', pendingRows);
const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][];
if (entries.length === 0) {
console.log('[handleSaveAll] No entries to save, returning');
return;
}
if (success) { setIsSavingAll(true);
console.log(`Successfully saved ${articleNo}`); let failedArticles: string[] = [];
setAppState(prev => ({ ...prev, hasUnsavedChanges: false })); let jwtExpired = false;
} else { const token = session?.access_token;
console.error(`Failed to save ${articleNo} to Supabase`);
alert("Error saving to database. Local changes will be lost on refresh if not saved."); try {
for (const [articleNo, { newData, originalData, articleName }] of entries) {
console.log('[handleSaveAll] Saving article:', articleNo);
const result = await saveRowToSupabase(articleNo, newData, token);
console.log('[handleSaveAll] Save result for', articleNo, ':', result);
if (result.success) {
// Also save to history
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown', token);
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
setPendingRows(prev => {
const n = { ...prev };
delete n[articleNo];
return n;
});
} else {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
failedArticles.push(`${articleNo} [${result.error || 'Unknown error'}]`);
if (result.error?.includes('401') || result.error?.includes('JWT expired')) {
jwtExpired = true;
}
}
}
console.log('[handleSaveAll] Finished loop. Failed:', failedArticles.length);
if (jwtExpired) {
alert("Tu sesión ha caducado (JWT expired). Por favor, haz clic en el botón de Cerrar Sesión (Sign out) arriba a la derecha y vuelve a iniciar sesión para guardar tus cambios.");
} else if (failedArticles.length > 0) {
alert(`Failed to save items:\n\n${failedArticles.join('\n')}\n\nPlease try again.`);
} else {
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
}
} catch (err: any) {
console.error('[handleSaveAll] Critical error:', err);
alert(`A critical error occurred while saving: ${err.message || err}\nCheck console for details.`);
} finally {
setIsSavingAll(false);
console.log('[handleSaveAll] isSavingAll set to false');
} }
}; };
@@ -254,15 +409,15 @@ export default function App() {
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
message message
}; };
// Keep only last 5 steps // Keep last 50 steps
const newHistory = [newState, ...prev].slice(0, 5); const newHistory = [newState, ...prev].slice(0, 50);
return newHistory; return newHistory;
}); });
}; };
const handleUndo = () => { const handleUndo = () => {
if (undoHistory.length === 0) return; if (undoHistory.length === 0) return;
const [lastAction, ...remainingHistory] = undoHistory; const [lastAction, ...remainingHistory] = undoHistory;
setAppState(prev => ({ setAppState(prev => ({
...prev, ...prev,
@@ -304,6 +459,14 @@ export default function App() {
}; };
}, [appState.data]); }, [appState.data]);
if (!session) {
return <LoginPage onLogin={() => {
const stored = getStoredSession();
console.log('[App] onLogin, stored session:', stored ? 'found' : 'null');
setSession(stored);
}} />;
}
return ( return (
<div className="h-screen bg-[#040d1a] text-slate-200 flex flex-col font-sans overflow-hidden"> <div className="h-screen bg-[#040d1a] text-slate-200 flex flex-col font-sans overflow-hidden">
<TopBar <TopBar
@@ -317,9 +480,14 @@ export default function App() {
onUndo={handleUndo} onUndo={handleUndo}
undoMessage={undoHistory[0]?.message} undoMessage={undoHistory[0]?.message}
undoSteps={undoHistory.length} undoSteps={undoHistory.length}
pendingCount={Object.keys(pendingRows).length}
pendingChanges={Object.fromEntries(Object.entries(pendingRows).map(([k, v]) => [k, { articleName: (v as any).articleName }]))}
onSaveAll={handleSaveAll}
onRevertRow={handleRevertRow}
isSavingAll={isSavingAll}
/> />
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} /> <Sidebar activeModule={activeModule} setActiveModule={setActiveModule} userEmail={session.user.email} />
<main className="flex-1 overflow-auto relative p-6 bg-[#041021]"> <main className="flex-1 overflow-auto relative p-6 bg-[#041021]">
{isLoadingDefault ? ( {isLoadingDefault ? (
<div className="flex flex-col items-center justify-center h-full text-slate-400"> <div className="flex flex-col items-center justify-center h-full text-slate-400">
@@ -345,13 +513,16 @@ export default function App() {
) : ( ) : (
<> <>
{activeModule === 'descriptions' && ( {activeModule === 'descriptions' && (
<ProductDescriptions <ProductDescriptions
data={appState.data} data={appState.data}
onEdit={(index) => setEditingRowIndex(index)} headers={appState.headers}
asinColumnIndex={appState.asinColumnIndex}
onEdit={(index) => setEditingRowIndex(index)}
rowStatuses={rowStatuses}
/> />
)} )}
{activeModule === 'matrix' && ( {activeModule === 'matrix' && (
<MatrixView data={appState.data} headers={appState.headers} /> <MatrixView data={appState.data} headers={appState.headers} rowStatuses={rowStatuses} />
)} )}
{activeModule === 'dimensions' && ( {activeModule === 'dimensions' && (
@@ -361,6 +532,8 @@ export default function App() {
onEdit={(index) => setEditingRowIndex(index)} onEdit={(index) => setEditingRowIndex(index)}
onSaveRow={handleSaveRow} onSaveRow={handleSaveRow}
onCaptureState={captureState} onCaptureState={captureState}
rowStatuses={rowStatuses}
onRevertRow={handleRevertRow}
/> />
)} )}
@@ -371,6 +544,65 @@ export default function App() {
onSaveRow={handleSaveRow} onSaveRow={handleSaveRow}
onCaptureState={captureState} onCaptureState={captureState}
onEdit={(index) => setEditingRowIndex(index)} onEdit={(index) => setEditingRowIndex(index)}
rowStatuses={rowStatuses}
/>
)}
{activeModule === 'article_details' && (
<ArticleDetails
data={appState.data}
onEdit={(index) => setEditingRowIndex(index)}
rowStatuses={rowStatuses}
/>
)}
{activeModule === 'pending_validation' && (
<PendingValidationView
data={appState.data}
pendingRows={pendingRows}
rowStatuses={rowStatuses}
onRevertRow={handleRevertRow}
onEdit={(index) => setEditingRowIndex(index)}
/>
)}
{activeModule === 'missing_data' && (
<MissingDataView
data={appState.data}
headers={appState.headers}
onSaveRow={handleSaveRow}
onCaptureState={captureState}
/>
)}
{activeModule === 'history' && (
<HistoryView
headers={appState.headers}
data={appState.data}
sessionToken={session?.access_token}
onEdit={(index) => setEditingRowIndex(index)}
onRevert={async (articleNo, revertedData, historyId) => {
// Find the row in appState.data and update it
const rowIndex = appState.data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === articleNo);
if (rowIndex !== -1) {
setAppState(prev => {
const newData = [...prev.data];
newData[rowIndex] = revertedData;
return { ...prev, data: newData, hasUnsavedChanges: true };
});
// Mark as pending for sync
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
setPendingRows(prev => ({
...prev,
[articleNo]: {
rowIndex,
originalData: appState.data[rowIndex],
newData: revertedData,
articleName: String(revertedData[COLUMNS.ARTICLE_NAME] || articleNo),
}
}));
// Delete the history entry after revert
if (historyId) {
await deleteHistoryEntry(String(historyId), session?.access_token);
}
}
}}
/> />
)} )}
</> </>
@@ -378,15 +610,20 @@ export default function App() {
</main> </main>
</div> </div>
<UndoToast <UndoToast
undoState={undoHistory[0] || null} undoState={undoHistory[0] || null}
onUndo={handleUndo} onUndo={handleUndo}
onClose={() => setUndoHistory([])} onClose={() => {
// Instead of clearing history, we can just hide the toast
// But since UndoToast is driven by undoHistory[0],
// we might want a way to "acknowledge" the current top of history
// For now, let's just not clear the history.
}}
/> />
{editingRowIndex !== null && ( {editingRowIndex !== null && (
<EditPanel <EditPanel
row={appState.data[editingRowIndex]} row={appState.data[editingRowIndex]}
rowIndex={editingRowIndex} rowIndex={editingRowIndex}
onSave={handleSaveRow} onSave={handleSaveRow}
onClose={() => setEditingRowIndex(null)} onClose={() => setEditingRowIndex(null)}
+375
View File
@@ -0,0 +1,375 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface ArticleDetailsProps {
data: ExcelRow[];
onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
}
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const [pageSize, setPageSize] = useState(100);
const [page, setPage] = useState(1);
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
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,
});
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
// Tab filter
if (activeTab === 'missingDetailsDE') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE]);
if (activeTab === 'missingDetailsEN') result = result.filter(r => !r.row[COLUMNS.DETAILS_EN]);
if (activeTab === 'missingAnyDetails') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE] || !r.row[COLUMNS.DETAILS_EN]);
if (activeTab === 'lowStock') result = result.filter(r => Number(r.row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0);
// Search filter
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
}
// Dropdown filters
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
// Column-specific filters (Excel-like)
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
const vals = selectedValues as string[];
if (vals.length > 0) {
result = result.filter(r => vals.includes(String(r.row[Number(colIdx)] || '')));
}
});
// Sorting
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, lineFilter, columnFilters, sortCol, sortDesc]);
const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize;
return filteredData.slice(start, start + pageSize);
}, [filteredData, page, pageSize]);
const totalPages = Math.ceil(filteredData.length / pageSize);
const handleSort = (col: number) => {
if (sortCol === col) {
setSortDesc(!sortDesc);
} else {
setSortCol(col);
setSortDesc(false);
}
};
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 values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort();
};
const toggleColumnFilter = (col: number, value: string) => {
setColumnFilters(prev => {
const current = prev[col] || [];
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value];
return { ...prev, [col]: next };
});
setPage(1);
};
const setBatchColumnFilter = (col: number, values: string[]) => {
setColumnFilters(prev => ({ ...prev, [col]: values }));
setPage(1);
};
const getBadge = (val: any, type: 'success' | 'warning' | 'error' | 'info' = 'info') => {
if (!val) return <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>;
const styles = {
success: "bg-green-500/10 text-green-400 border-green-500/20",
warning: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
error: "bg-red-500/10 text-red-400 border-red-500/20",
info: "bg-blue-500/10 text-blue-400 border-blue-500/20"
};
return <span className={cn("inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border", styles[type])}>{val}</span>;
};
const tabs: { id: TabType; label: string }[] = [
{ id: 'all', label: 'All Articles' },
{ id: 'missingDetailsDE', label: 'No Details DE' },
{ id: 'missingDetailsEN', label: 'No Details EN' },
{ id: 'missingAnyDetails', label: 'Missing Details' },
{ id: 'lowStock', label: 'Out of Stock' },
];
return (
<div className="flex flex-col h-full">
<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>
))}
</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>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button
onClick={() => {
setColumnFilters({});
setPage(1);
}}
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"
>
<X className="w-4 h-4" />
Clear All Column Filters
</button>
)}
<select
value={lineFilter}
onChange={e => { setLineFilter(e.target.value); setPage(1); }}
className="bg-slate-900 border border-slate-700 rounded-md px-4 py-2 text-sm text-white focus:outline-none focus:border-indigo-500"
>
<option value="">All Lines</option>
{lines.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
</select>
</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" style={{ tableLayout: 'fixed' }}>
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
<tr>
{[
{ col: COLUMNS.ARTICLE_NO, label: 'SKU' },
{ col: COLUMNS.ARTICLE_NAME, label: 'Name' },
{ col: COLUMNS.CLASSIFICATION, label: 'Class' },
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Stock' },
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
].map(({ col, label }) => (
<th
key={col}
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 overflow-hidden">
<span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{label}
{sortCol === col && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setOpenFilterCol(openFilterCol === col ? null : col);
}}
className={cn(
"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"
)}
>
<Filter className="w-3 h-3" />
</button>
</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 && (
<ColumnFilterPopover
uniqueValues={getUniqueValues(col)}
selectedValues={columnFilters[col] || []}
onToggle={(val) => toggleColumnFilter(col, val)}
onSelectAll={(vals) => setBatchColumnFilter(col, vals)}
onClear={() => {
setColumnFilters(prev => {
const next = { ...prev };
delete next[col];
return next;
});
setOpenFilterCol(null);
}}
onClose={() => setOpenFilterCol(null)}
/>
)}
</th>
))}
<th className="px-3 py-3 font-medium text-right" style={{ width: 80 }}>Edit</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{paginatedData.map(({ row, index }) => {
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
return (
<tr
key={index}
className={cn(
"hover:bg-slate-700/20 transition-colors",
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
)}
>
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
{row[COLUMNS.ARTICLE_NAME]}
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
<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>
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
<span className={cn(
"font-mono font-bold",
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
)}>
{row[COLUMNS.ITEM_AVAILABLE] || 0}
</span>
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
{row[COLUMNS.DETAILS_DE] ? (
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
) : getBadge(null)}
</td>
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
{row[COLUMNS.DETAILS_EN] ? (
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
) : getBadge(null)}
</td>
<td className="px-3 py-2 text-right">
<button
onClick={() => onEdit(index)}
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={7} className="px-4 py-8 text-center text-slate-500">
No articles 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} articles</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>
);
}
+122
View File
@@ -0,0 +1,122 @@
import React, { useState, useMemo } from 'react';
import { Search, Check, X } from 'lucide-react';
import { cn } from '../lib/utils';
interface ColumnFilterPopoverProps {
uniqueValues: string[];
selectedValues: string[];
onToggle: (val: string) => void;
onSelectAll: (vals: string[]) => void;
onClear: () => void;
onClose: () => void;
title?: string;
className?: string;
}
export function ColumnFilterPopover({
uniqueValues,
selectedValues,
onToggle,
onSelectAll,
onClear,
onClose,
title,
className
}: ColumnFilterPopoverProps) {
const [search, setSearch] = useState('');
const filteredValues = useMemo(() => {
return uniqueValues.filter(v =>
String(v || '').toLowerCase().includes(search.toLowerCase())
);
}, [uniqueValues, search]);
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
return (
<div
className={cn(
"absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100",
className
)}
onClick={(e) => e.stopPropagation()}
>
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Filter values..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 pr-7 text-xs text-white focus:outline-none focus:border-blue-500"
autoFocus
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
{filteredValues.map(val => (
<div
key={val}
role="checkbox"
aria-checked={selectedValues.includes(val)}
tabIndex={0}
onClick={() => onToggle(val)}
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); } }}
className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group"
>
<div className={cn(
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
)}>
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
</div>
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</div>
))}
{filteredValues.length === 0 && (
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
)}
</div>
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
<div className="flex items-center gap-3">
<button
onClick={() => {
if (isAllSelected) {
onSelectAll([]);
} else {
onSelectAll(uniqueValues);
}
}}
className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight"
>
{isAllSelected ? 'Deselect All' : 'Select All'}
</button>
<span className="text-slate-600 font-bold"></span>
<button
onClick={onClear}
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
>
Clear Current
</button>
</div>
<button
onClick={onClose}
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg active:scale-95 uppercase"
>
OK
</button>
</div>
</div>
);
}
+3 -1
View File
@@ -21,6 +21,8 @@ export function DataCompleteness({ data, headers }: DataCompletenessProps) {
COLUMNS.TARIFF_CODE, COLUMNS.TARIFF_CODE,
COLUMNS.COUNTRY_ORIGIN, COLUMNS.COUNTRY_ORIGIN,
COLUMNS.RECOMMENDED_AGE, COLUMNS.RECOMMENDED_AGE,
COLUMNS.DETAILS_DE,
COLUMNS.DETAILS_EN,
COLUMNS.LONG_DE, COLUMNS.LONG_DE,
COLUMNS.LONG_EN, COLUMNS.LONG_EN,
COLUMNS.SHORT_DE, COLUMNS.SHORT_DE,
@@ -70,7 +72,7 @@ export function DataCompleteness({ data, headers }: DataCompletenessProps) {
<div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden"> <div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-700 bg-slate-800/50"> <div className="p-4 border-b border-slate-700 bg-slate-800/50">
<h2 className="text-lg font-semibold text-white">Data Completeness</h2> <h2 className="text-lg font-semibold text-white">Data Completeness</h2>
<p className="text-sm text-slate-400">Evaluating 10 key fields per product.</p> <p className="text-sm text-slate-400">Evaluating 12 key fields per product.</p>
</div> </div>
<div className="overflow-x-auto flex-1"> <div className="overflow-x-auto flex-1">
+355 -78
View File
@@ -1,8 +1,9 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2 } from 'lucide-react'; import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X, Undo2 } 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';
interface DimensionsViewProps { interface DimensionsViewProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -10,6 +11,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 {
@@ -31,7 +34,7 @@ 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 [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);
@@ -41,6 +44,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
sourceRow: ExcelRow, sourceRow: ExcelRow,
fieldType: 'outer' | 'units' | 'moq' | 'all' fieldType: 'outer' | 'units' | 'moq' | 'all'
} | null>(null); } | null>(null);
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
clusterKey: string;
targetGroupKey: string;
selectedIndices: number[];
} | null>(null);
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState<string[]>([]);
const [classFilter, setClassFilter] = useState<string[]>([]);
const [openFilter, setOpenFilter] = useState<'line' | 'class' | 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 }[]>();
@@ -87,13 +101,15 @@ 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,
@@ -106,8 +122,38 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
}, [data]); }, [data]);
const filteredGroups = useMemo(() => { const filteredGroups = useMemo(() => {
return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups; let result = groups;
}, [groups, showOnlyInconsistent]); 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) {
const s = search.toLowerCase();
result = result.filter(g => {
const matchesSearch = !search || g.rows.some(({ row }) =>
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
const 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] || '')));
return matchesSearch && matchesLine && matchesClass;
});
}
return result;
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter, rowStatuses]);
const uniqueLines = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
, [data]);
const uniqueClasses = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.CLASSIFICATION] || '')))).sort()
, [data]);
const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => { const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => {
// Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute // Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute
@@ -177,6 +223,49 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
setPendingAction({ group, sourceRow, fieldType: 'all' }); setPendingAction({ group, sourceRow, fieldType: 'all' });
}; };
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 () => {
if (!pendingNearDupSync) return;
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
if (!cluster) return;
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
if (!targetGroup) return;
const sourceRow = targetGroup.rows[0].row;
const innerL = sourceRow[COLUMNS.INNER_L];
const innerW = sourceRow[COLUMNS.INNER_W];
const innerH = sourceRow[COLUMNS.INNER_H];
onCaptureState(`Synced inner dimensions to ${targetGroupKey} cm for ${selectedIndices.length} products`);
setPendingNearDupSync(null);
for (const idx of selectedIndices) {
const row = data[idx];
const updatedRow = [...row];
updatedRow[COLUMNS.INNER_L] = innerL;
updatedRow[COLUMNS.INNER_W] = innerW;
updatedRow[COLUMNS.INNER_H] = innerH;
await onSaveRow(idx, updatedRow);
}
setClusterSelections(prev => {
const next = { ...prev };
delete next[pendingNearDupSync.clusterKey];
return next;
});
};
const executeSync = async () => { const executeSync = async () => {
if (!pendingAction) return; if (!pendingAction) return;
const { group, sourceRow, fieldType } = pendingAction; const { group, sourceRow, fieldType } = pendingAction;
@@ -222,17 +311,91 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between 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> <div className="relative flex-1 min-w-[250px]">
<h2 className="text-xl font-semibold text-white flex items-center gap-2"> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Boxes className="text-blue-400" /> <input
Dimension Consistency Check type="text"
</h2> placeholder="Search SKU or Name in groups..."
<p className="text-sm text-slate-400 mt-1"> value={search}
Grouping products by Inner Box dimensions to find Packaging or MOQ discrepancies. onChange={e => setSearch(e.target.value)}
</p> 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-4">
<div className="flex items-center gap-2">
<div className="relative">
<button
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
lineFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
)}
>
<Filter className="w-4 h-4" />
Line {lineFilter.length > 0 && `(${lineFilter.length})`}
</button>
{openFilter === 'line' && (
<ColumnFilterPopover
uniqueValues={uniqueLines}
selectedValues={lineFilter}
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
onSelectAll={setLineFilter}
onClear={() => setLineFilter([])}
onClose={() => setOpenFilter(null)}
title="Filter by Line"
className="left-auto right-0"
/>
)}
</div>
<div className="relative">
<button
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
classFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
)}
>
<Filter className="w-4 h-4" />
Class {classFilter.length > 0 && `(${classFilter.length})`}
</button>
{openFilter === 'class' && (
<ColumnFilterPopover
uniqueValues={uniqueClasses}
selectedValues={classFilter}
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
onSelectAll={setClassFilter}
onClear={() => setClassFilter([])}
onClose={() => setOpenFilter(null)}
title="Filter by Classification"
className="left-auto right-0"
/>
)}
</div>
{(lineFilter.length > 0 || classFilter.length > 0 || search) && (
<button
onClick={() => { setSearch(''); setLineFilter([]); setClassFilter([]); }}
className="p-2 text-red-400 hover:text-red-300 transition-colors"
title="Clear all filters"
>
<X className="w-5 h-5" />
</button>
)}
</div>
<div className="h-8 w-px bg-slate-700 mx-2 hidden sm:block" />
<div className="flex items-center gap-4 ml-auto">
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer"> <label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -240,10 +403,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
onChange={e => setShowOnlyInconsistent(e.target.checked)} onChange={e => setShowOnlyInconsistent(e.target.checked)}
className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500" className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
/> />
Show only inconsistent groups Show only inconsistent
</label> </label>
<div className="text-xs text-slate-500 bg-slate-900 px-3 py-1.5 rounded-full border border-slate-700"> <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} Inconsistencies found {groups.filter(g => g.isInconsistent).length} ISSUES
</div> </div>
</div> </div>
</div> </div>
@@ -257,58 +420,129 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
{nearDuplicateClusters.length} cluster{nearDuplicateClusters.length !== 1 ? 's' : ''} with dimensions differing &lt;1 cm per axis {nearDuplicateClusters.length} cluster{nearDuplicateClusters.length !== 1 ? 's' : ''} with dimensions differing &lt;1 cm per axis
</span> </span>
</div> </div>
{nearDuplicateClusters.map((cluster, ci) => ( {nearDuplicateClusters.map((cluster, ci) => {
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden"> const allClusterRows = cluster.groups.flatMap(g => g.rows);
<button const clusterKey = cluster.groups[0].key;
onClick={() => { const selection = clusterSelections[clusterKey] ?? new Set<number>();
const next = new Set(expandedNearDuplicates); const targetKey = clusterSyncTargets[clusterKey] ?? cluster.groups[0].key;
next.has(ci) ? next.delete(ci) : next.add(ci);
setExpandedNearDuplicates(next); return (
}} <div key={clusterKey} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left" <button
> onClick={() => {
{expandedNearDuplicates.has(ci) ? <ChevronDown className="w-4 h-4 text-slate-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-slate-500 shrink-0" />} const next = new Set(expandedNearDuplicates);
<div className="flex items-center gap-3 flex-wrap"> next.has(ci) ? next.delete(ci) : next.add(ci);
{cluster.groups.map((g, gi) => ( setExpandedNearDuplicates(next);
<span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded"> }}
{g.key} cm className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span> >
</span> {expandedNearDuplicates.has(ci) ? <ChevronDown className="w-4 h-4 text-slate-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-slate-500 shrink-0" />}
))} <div className="flex items-center gap-3 flex-wrap flex-1">
<span className="text-xs text-violet-400/70"> {cluster.groups.map((g, gi) => (
max diff {cluster.maxDiffPct.toFixed(1)} cm <span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded">
</span> {g.key} cm
</div> <span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
</button> </span>
{expandedNearDuplicates.has(ci) && ( ))}
<div className="border-t border-violet-500/20 px-4 py-3 space-y-2"> <span className="text-xs text-violet-400/70"> max diff {cluster.maxDiffPct.toFixed(1)} cm</span>
{cluster.groups.map((g, gi) => ( </div>
<div key={g.key} className="flex items-start gap-4 text-xs"> <span className="text-xs text-slate-500 shrink-0">{allClusterRows.length} products</span>
<span className="font-mono text-violet-300 w-32 shrink-0 pt-0.5">{g.key} cm</span> </button>
<div>
<span className="text-slate-400">{cluster.volumes[gi].toLocaleString()} cm³</span> {expandedNearDuplicates.has(ci) && (
<span className="text-slate-600 mx-2">·</span> <div className="border-t border-violet-500/20">
<span className="text-slate-500">{g.rows.length} product{g.rows.length !== 1 ? 's' : ''}: </span> <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-slate-400"> <span className="text-xs text-slate-400">Sync selected to:</span>
{g.rows.slice(0, 5).map(r => r.row[COLUMNS.ARTICLE_NO]).join(', ')} <select
{g.rows.length > 5 && <span className="text-slate-600"> +{g.rows.length - 5} more</span>} value={targetKey}
</span> onChange={e => setClusterSyncTargets(prev => ({ ...prev, [clusterKey]: e.target.value }))}
</div> 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 => {
const repr = g.rows[0].row;
return (
<option key={g.key} value={g.key}>
{repr[COLUMNS.INNER_L]} × {repr[COLUMNS.INNER_W]} × {repr[COLUMNS.INNER_H]} cm
</option>
);
})}
</select>
<button
disabled={selection.size === 0}
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"
>
<Layers className="w-3 h-3" />
Sync {selection.size} selected
</button>
<button
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set(allClusterRows.map(r => r.index)) }))}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Select all
</button>
{selection.size > 0 && (
<button
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set() }))}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Clear
</button>
)}
</div> </div>
))}
</div> <div className="divide-y divide-violet-500/10">
)} {allClusterRows.map(({ row, index }) => {
</div> const isSelected = selection.has(index);
))} return (
<div
key={index}
className={cn(
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors",
isSelected && "bg-violet-500/10"
)}
onClick={() => setClusterSelections(prev => {
const current = new Set(prev[clusterKey] ?? []);
if (current.has(index)) current.delete(index); else current.add(index);
return { ...prev, [clusterKey]: current };
})}
>
<input
type="checkbox"
checked={isSelected}
readOnly
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-slate-300 font-medium shrink-0">{row[COLUMNS.ARTICLE_NO]}</span>
<span className="text-xs text-slate-500 truncate">{row[COLUMNS.ARTICLE_NAME]}</span>
</div>
</div>
<span className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded shrink-0">
{row[COLUMNS.INNER_L]} × {row[COLUMNS.INNER_W]} × {row[COLUMNS.INNER_H]} cm
</span>
</div>
);
})}
</div>
</div>
)}
</div>
);
})}
</div> </div>
)} )}
<div className="space-y-3"> <div className="space-y-3">
{filteredGroups.map(group => ( {filteredGroups.map(group => {
<div key={group.key} className={cn( const hasPending = group.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending');
"border rounded-lg overflow-hidden transition-all", return (
group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30" <div key={group.key} className={cn(
)}> "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"
)}>
<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">
<button <button
onClick={() => toggleGroup(group.key)} onClick={() => toggleGroup(group.key)}
@@ -320,17 +554,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
@@ -357,6 +596,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
@@ -379,14 +629,22 @@ 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';
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>
</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",
@@ -401,7 +659,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
> >
{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" /> : <Layers 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>
</div> </div>
</td> </td>
<td className={cn( <td className={cn(
@@ -417,7 +675,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
> >
{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" /> : <Layers 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>
</div> </div>
</td> </td>
<td className={cn( <td className={cn(
@@ -433,36 +691,45 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
> >
{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" /> : <Layers 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>
</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">
@@ -484,6 +751,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
type="warning" type="warning"
confirmText="Sync Group" confirmText="Sync Group"
/> />
<ConfirmModal
isOpen={!!pendingNearDupSync}
onConfirm={executeNearDupSync}
onCancel={() => setPendingNearDupSync(null)}
title="Sync Inner Dimensions"
message={`Update inner dimensions to ${pendingNearDupSync?.targetGroupKey} cm for ${pendingNearDupSync?.selectedIndices.length} selected product${(pendingNearDupSync?.selectedIndices.length ?? 0) !== 1 ? 's' : ''}?`}
type="warning"
confirmText="Sync Dimensions"
/>
</div> </div>
); );
} }
+243 -83
View File
@@ -1,8 +1,9 @@
import React, { useState } from 'react'; import React, { useState, useRef, useCallback } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { X, Sparkles, Save, Loader2, Languages, Package } from 'lucide-react'; 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,26 +14,151 @@ 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>
);
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>
<textarea
id={`field-${field}`}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={onKeyDown}
className={cn(
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
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 [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] || '',
shortDe: row[COLUMNS.SHORT_DE] || '', shortDe: row[COLUMNS.SHORT_DE] || '',
shortEn: row[COLUMNS.SHORT_EN] || '', shortEn: row[COLUMNS.SHORT_EN] || '',
innerW: row[COLUMNS.INNER_W] || '', detailsDe: row[COLUMNS.DETAILS_DE] || '',
innerL: row[COLUMNS.INNER_L] || '', detailsEn: row[COLUMNS.DETAILS_EN] || '',
innerH: row[COLUMNS.INNER_H] || '', innerW: row[COLUMNS.INNER_W] !== undefined && row[COLUMNS.INNER_W] !== null ? String(row[COLUMNS.INNER_W]) : '',
outerW: row[COLUMNS.OUTER_W] || '', innerL: row[COLUMNS.INNER_L] !== undefined && row[COLUMNS.INNER_L] !== null ? String(row[COLUMNS.INNER_L]) : '',
outerL: row[COLUMNS.OUTER_L] || '', innerH: row[COLUMNS.INNER_H] !== undefined && row[COLUMNS.INNER_H] !== null ? String(row[COLUMNS.INNER_H]) : '',
outerH: row[COLUMNS.OUTER_H] || '', outerW: row[COLUMNS.OUTER_W] !== undefined && row[COLUMNS.OUTER_W] !== null ? String(row[COLUMNS.OUTER_W]) : '',
unitsOuter: row[COLUMNS.UNITS_OUTER] || '', outerL: row[COLUMNS.OUTER_L] !== undefined && row[COLUMNS.OUTER_L] !== null ? String(row[COLUMNS.OUTER_L]) : '',
moq: row[COLUMNS.MOQ] || '', outerH: row[COLUMNS.OUTER_H] !== undefined && row[COLUMNS.OUTER_H] !== null ? String(row[COLUMNS.OUTER_H]) : '',
unitsOuter: row[COLUMNS.UNITS_OUTER] !== undefined && row[COLUMNS.UNITS_OUTER] !== null ? String(row[COLUMNS.UNITS_OUTER]) : '',
moq: row[COLUMNS.MOQ] !== undefined && row[COLUMNS.MOQ] !== null ? String(row[COLUMNS.MOQ]) : '',
}); });
const [loadingField, setLoadingField] = useState<string | null>(null); const [loadingField, setLoadingField] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false);
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 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> = {
@@ -48,8 +174,12 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
outerH: COLUMNS.OUTER_H, outerH: COLUMNS.OUTER_H,
unitsOuter: COLUMNS.UNITS_OUTER, unitsOuter: COLUMNS.UNITS_OUTER,
moq: COLUMNS.MOQ, moq: COLUMNS.MOQ,
detailsDe: COLUMNS.DETAILS_DE,
detailsEn: COLUMNS.DETAILS_EN,
}; };
return formData[field] !== (row[colMap[field]] || ''); const colIndex = (colMap as Record<string, number>)[field as string];
if (colIndex === undefined) return false;
return formData[field] !== (row[colIndex] !== undefined && row[colIndex] !== null ? String(row[colIndex]) : '');
}; };
const handleGenerate = async (field: keyof typeof formData) => { const handleGenerate = async (field: keyof typeof formData) => {
@@ -65,9 +195,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
try { try {
let prompt = ''; let prompt = '';
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${row[COLUMNS.DETAILS_EN] || 'N/A'}\nArticle Details (DE): ${row[COLUMNS.DETAILS_DE] || '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 contain only the final product description.";
if (field === 'longDe') { if (field === 'longDe') {
if (formData.longEn) { if (formData.longEn) {
@@ -77,6 +207,7 @@ 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.
English Text to Translate: English Text to Translate:
${formData.longEn}`; ${formData.longEn}`;
@@ -91,6 +222,7 @@ 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.
German Text to Translate: German Text to Translate:
${formData.longDe}`; ${formData.longDe}`;
@@ -99,17 +231,19 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
} }
} 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: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortEn}`;
} else if (formData.longDe) { } else if (formData.longDe) {
prompt = `Create a short version (2-4 sentences max) of the following German product description:\n\n${formData.longDe}`; const targetChars = Math.round(formData.longDe.length * 0.3);
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
} 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 (2-4 sentences max).\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: Translate everything, including any ALL CAPS phrases, maintaining the all-caps formatting:\n\n${formData.shortDe}`;
} else if (formData.longEn) { } else if (formData.longEn) {
prompt = `Create a short version (2-4 sentences max) of the following English product description:\n\n${formData.longEn}`; const targetChars = Math.round(formData.longEn.length * 0.3);
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
} 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 (2-4 sentences max).\n\n${baseContext}`;
} }
@@ -117,6 +251,12 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
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));
// Focus the textarea so user can edit immediately after AI generation
setTimeout(() => {
const textarea = document.getElementById(`field-${field}`);
if (textarea) textarea.focus();
}, 100);
} catch (err: any) { } catch (err: any) {
setError(err.message || 'An error occurred during generation.'); setError(err.message || 'An error occurred during generation.');
} finally { } finally {
@@ -143,60 +283,14 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
newRow[COLUMNS.OUTER_H] = formData.outerH; newRow[COLUMNS.OUTER_H] = formData.outerH;
newRow[COLUMNS.UNITS_OUTER] = formData.unitsOuter; newRow[COLUMNS.UNITS_OUTER] = formData.unitsOuter;
newRow[COLUMNS.MOQ] = formData.moq; newRow[COLUMNS.MOQ] = formData.moq;
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
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>
</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 (
<> <>
@@ -230,11 +324,27 @@ 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>
<p className="text-sm text-slate-300 line-clamp-2" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE] || '-'}</p> <textarea
value={formData.detailsDe}
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
className={cn(
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
isModified('detailsDe') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
)}
placeholder="Enter details in German..."
/>
</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>
<p className="text-sm text-slate-300 line-clamp-2" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN] || '-'}</p> <textarea
value={formData.detailsEn}
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
className={cn(
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
isModified('detailsEn') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
)}
placeholder="Enter details in English..."
/>
</div> </div>
</div> </div>
@@ -245,28 +355,78 @@ 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>
</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 || !!formData.longDe}
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 || !!formData.longEn}
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">
@@ -281,7 +441,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
+258
View File
@@ -0,0 +1,258 @@
import React, { useState, useEffect } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag, Search, X, Edit2 } from 'lucide-react';
import { getHistory, deleteHistoryEntry, HistoryEntry } from '../lib/supabase';
import { ExcelRow, COLUMNS } from '../types';
import { cn } from '../lib/utils';
interface HistoryViewProps {
headers: string[];
data: ExcelRow[];
onRevert: (articleNo: string, oldData: ExcelRow, historyId?: number) => void;
onEdit?: (rowIndex: number) => void;
sessionToken?: string;
}
export function HistoryView({ headers, data, onRevert, onEdit, sessionToken }: HistoryViewProps) {
const [history, setHistory] = useState<HistoryEntry[]>([]);
const [loading, setLoading] = useState(true);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [search, setSearch] = useState('');
useEffect(() => {
loadHistory();
}, []);
const loadHistory = async () => {
setLoading(true);
const data = await getHistory(sessionToken);
setHistory(data);
setLoading(false);
};
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 filteredHistory = history.filter(entry =>
entry.article_name?.toLowerCase().includes(search.toLowerCase()) ||
entry.product_id.toLowerCase().includes(search.toLowerCase()) ||
entry.changed_by?.toLowerCase().includes(search.toLowerCase())
);
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="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<History className="w-8 h-8 text-blue-500" />
Change History
</h1>
<p className="text-slate-400 mt-1">Review and revert any changes made to products.</p>
</div>
<div className="flex items-center gap-4">
<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>
</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) => {
const isExpanded = expandedId === entry.id;
const changes = getChangedFields(entry.old_data, entry.new_data);
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)}
>
{isExpanded ? <ChevronDown className="w-5 h-5 text-slate-500" /> : <ChevronRight className="w-5 h-5 text-slate-500" />}
<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 items-center justify-end gap-3 text-sm">
<span className="px-2.5 py-1 rounded-full bg-blue-500/10 text-blue-400 font-medium">
{changes.length} {changes.length === 1 ? 'change' : 'changes'}
</span>
<button
onClick={(e) => {
e.stopPropagation();
if (onEdit) {
const idx = data.findIndex(r => String(r[COLUMNS.ARTICLE_NO]) === entry.product_id);
if (idx !== -1) onEdit(idx);
}
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors border border-blue-500/20"
>
<Edit2 className="w-4 h-4" />
Edit
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleRevert(entry);
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md bg-orange-500/10 text-orange-400 hover:bg-orange-500/20 transition-colors border border-orange-500/20"
>
<RotateCcw className="w-4 h-4" />
Revert
</button>
</div>
</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">Field</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-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>
+170 -11
View File
@@ -1,19 +1,100 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow } from '../types'; import { ExcelRow } from '../types';
import { Search, Filter, ChevronDown, ChevronUp, X } from 'lucide-react';
import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
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 [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(25);
const [search, setSearch] = useState('');
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
// Global search
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
r.row.some(cell => String(cell || '').toLowerCase().includes(s))
);
}
// Column-specific filters
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
const vals = selectedValues as string[];
if (vals.length > 0) {
result = result.filter(r => vals.includes(String(r.row[Number(colIdx)] || '')));
}
});
// Sorting
if (sortCol !== null) {
result.sort((a, b) => {
const valA = String(a.row[sortCol] || '').toLowerCase();
const valB = String(b.row[sortCol] || '').toLowerCase();
// Handle numbers sorting correctly
const numA = parseFloat(valA.replace(',', '.'));
const numB = parseFloat(valB.replace(',', '.'));
if (!isNaN(numA) && !isNaN(numB)) {
return sortDesc ? numB - numA : numA - numB;
}
return sortDesc
? valB.localeCompare(valA)
: valA.localeCompare(valB);
});
}
return result;
}, [data, search, columnFilters, sortCol, sortDesc]);
const paginatedData = useMemo(() => { const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize; const start = (page - 1) * pageSize;
return data.slice(start, start + pageSize); return filteredData.slice(start, start + pageSize);
}, [data, page, pageSize]); }, [filteredData, page, pageSize]);
const handleSort = (col: number) => {
if (sortCol === col) {
setSortDesc(!sortDesc);
} else {
setSortCol(col);
setSortDesc(false);
}
};
const getUniqueValues = (col: number) => {
const values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort();
};
const toggleColumnFilter = (col: number, value: string) => {
setColumnFilters(prev => {
const current = prev[col] || [];
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value];
return { ...prev, [col]: next };
});
setPage(1);
};
const setBatchColumnFilter = (col: number, values: string[]) => {
setColumnFilters(prev => ({ ...prev, [col]: values }));
setPage(1);
};
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 '';
@@ -72,13 +153,47 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
return val; return val;
}; };
const totalPages = Math.ceil(data.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="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
<div className="p-4 border-b border-slate-700 bg-slate-800/50"> <div className="p-4 border-b border-slate-700 bg-slate-800/50 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">Matrix View</h2> <div>
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p> <h2 className="text-lg font-semibold text-white">Matrix View</h2>
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
</div>
<div className="flex items-center gap-4">
<div className="relative min-w-[300px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Search all columns..."
value={search}
onChange={e => { setSearch(e.target.value); setPage(1); }}
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>
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
<button
onClick={() => {
setColumnFilters({});
setPage(1);
}}
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"
>
<X className="w-4 h-4" />
Clear All Column Filters
</button>
)}
</div>
</div> </div>
<div className="overflow-auto flex-1"> <div className="overflow-auto flex-1">
@@ -86,14 +201,58 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
<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 key={index} className="px-4 py-3 font-medium border-b border-slate-700"> <th
{header} key={index}
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
>
<div className="flex items-center overflow-hidden">
<span
className="flex items-center gap-1 cursor-pointer hover:text-white"
onClick={() => handleSort(index)}
>
{header}
{sortCol === index && (
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setOpenFilterCol(openFilterCol === index ? null : index);
}}
className={cn(
"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"
)}
>
<Filter className="w-3.5 h-3.5" />
</button>
</div>
{openFilterCol === index && (
<ColumnFilterPopover
uniqueValues={getUniqueValues(index)}
selectedValues={columnFilters[index] || []}
onToggle={(val) => toggleColumnFilter(index, val)}
onSelectAll={(vals) => setBatchColumnFilter(index, vals)}
onClear={() => {
setColumnFilters(prev => {
const next = { ...prev };
delete next[index];
return next;
});
setOpenFilterCol(null);
}}
onClose={() => setOpenFilterCol(null)}
title={header}
/>
)}
</th> </th>
))} ))}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-700/50"> <tbody className="divide-y divide-slate-700/50">
{paginatedData.map((row, rowIndex) => ( {paginatedData.map(({ row, index: rowIndex }) => (
<tr key={rowIndex} className="hover:bg-slate-700/30 transition-colors"> <tr key={rowIndex} className="hover:bg-slate-700/30 transition-colors">
{headers.map((header, colIndex) => { {headers.map((header, colIndex) => {
const formattedValue = formatCellValue(row[colIndex], header); const formattedValue = formatCellValue(row[colIndex], header);
@@ -122,7 +281,7 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-sm text-slate-400"> <div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-sm text-slate-400">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span>Showing {Math.min((page - 1) * pageSize + 1, data.length)} to {Math.min(page * pageSize, data.length)} of {data.length} entries</span> <span>Showing {Math.min((page - 1) * pageSize + 1, filteredData.length)} to {Math.min(page * pageSize, filteredData.length)} of {filteredData.length} entries</span>
<select <select
value={pageSize} value={pageSize}
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
+440
View File
@@ -0,0 +1,440 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Search, ChevronDown, ChevronUp, X, Edit2, Save, Filter, XCircle } from 'lucide-react';
import { cn } from '../lib/utils';
interface MissingDataViewProps {
data: ExcelRow[];
headers: string[];
onSaveRow: (rowIndex: number, updatedRow: ExcelRow) => void;
onCaptureState: (message: string) => void;
}
function formatDateValue(val: any): string {
if (val === null || val === undefined || val === '') return '';
if (typeof val === 'number') {
if (val >= 25569 && val <= 60000) {
const excelEpoch = new Date(1899, 11, 30);
const date = new Date(excelEpoch.getTime() + val * 86400000);
return date.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
return '';
}
return 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 = 'missingClass' | 'missingLaunch';
interface EditingState {
rowIndex: number;
classification: string;
launchDate: string;
readyToOrder: string;
}
export function MissingDataView({ data, headers, onSaveRow, onCaptureState }: MissingDataViewProps) {
const [activeTab, setActiveTab] = useState<TabType>('missingClass');
const [search, setSearch] = useState('');
const [sortCol, setSortCol] = useState<number | null>(null);
const [sortDesc, setSortDesc] = useState(false);
const [page, setPage] = useState(1);
const [editing, setEditing] = useState<EditingState | null>(null);
const [columnFilters, setColumnFilters] = useState<Record<number, string>>({});
const [showFilterMenu, setShowFilterMenu] = useState<number | null>(null);
const pageSize = 100;
const launchDateCol = useMemo(() =>
headers.findIndex(h => h.toLowerCase().includes('launch')), [headers]);
const readyToOrderCol = useMemo(() =>
headers.findIndex(h => h.toLowerCase().includes('ready')), [headers]);
const launchHeader = launchDateCol >= 0 ? headers[launchDateCol] : 'Launch Date';
const readyHeader = readyToOrderCol >= 0 ? headers[readyToOrderCol] : 'Ready to Order';
const filteredData = useMemo(() => {
let result = data.map((row, index) => ({ row, index }));
if (activeTab === 'missingClass') {
result = result.filter(r => {
const val = r.row[COLUMNS.CLASSIFICATION];
return !val || String(val).trim() === '';
});
}
if (activeTab === 'missingLaunch') {
result = result.filter(r => {
const val = launchDateCol >= 0 ? r.row[launchDateCol] : undefined;
return isEmptyOrEpoch(val);
});
}
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
}
(Object.entries(columnFilters) as [string, string][]).forEach(([colIdx, filterValue]) => {
if (!filterValue) return;
const colIdxNum = parseInt(colIdx);
const filterLower = filterValue.toLowerCase();
result = result.filter(r => {
const val: any = r.row[colIdxNum];
const displayVal = colIdxNum === launchDateCol || colIdxNum === readyToOrderCol
? formatDateValue(val) || String(val ?? '')
: String(val ?? '');
return displayVal.toLowerCase().includes(filterLower);
});
});
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 ? formatDateValue(row[launchDateCol]) || String(row[launchDateCol] ?? '') : '',
readyToOrder: readyToOrderCol >= 0 ? 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] = editing.launchDate;
if (readyToOrderCol >= 0) newRow[readyToOrderCol] = editing.readyToOrder;
onSaveRow(editing.rowIndex, newRow);
setEditing(null);
};
const tabs: { id: TabType; label: string }[] = [
{ id: 'missingClass', label: 'Missing Classification' },
{ id: 'missingLaunch', label: 'Missing Launch Date' },
];
const columns = [
{ col: COLUMNS.ARTICLE_NO, label: 'SKU', width: 100 },
{ col: COLUMNS.ARTICLE_NAME, label: 'Name', width: 220 },
{ col: COLUMNS.CLASSIFICATION, label: 'Classification', width: 130 },
...(launchDateCol >= 0 ? [{ col: launchDateCol, label: launchHeader, width: 130 }] : []),
...(readyToOrderCol >= 0 ? [{ col: readyToOrderCol, label: readyHeader, width: 130 }] : []),
];
const editingRow = editing ? data[editing.rowIndex] : null;
return (
<div className="flex flex-col h-full">
<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>
))}
</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 }) => (
<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">
<input
type="text"
placeholder="Filter..."
value={columnFilters[col] || ''}
onChange={(e) => {
setColumnFilters(prev => ({ ...prev, [col]: e.target.value }));
setPage(1);
}}
onClick={(e) => e.stopPropagation()}
className="w-full px-1.5 py-0.5 bg-slate-800 border border-slate-600 rounded text-[10px] text-white placeholder-slate-500 focus:outline-none focus:border-indigo-500"
/>
{columnFilters[col] && (
<button
onClick={(e) => {
e.stopPropagation();
setColumnFilters(prev => { const n = { ...prev }; delete n[col]; return n; });
setPage(1);
}}
className="absolute right-1 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white"
>
<XCircle className="w-3 h-3" />
</button>
)}
</div>
</th>
))}
<th className="px-2 py-2 font-medium text-right" style={{ width: 60 }}></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{paginatedData.map(({ row, index }) => (
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
<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>
)}
<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>
<input
type="text"
value={editing.classification}
onChange={e => setEditing(prev => prev ? { ...prev, classification: e.target.value } : prev)}
placeholder="e.g. CORE, OOC..."
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
editing.classification !== String(editingRow[COLUMNS.CLASSIFICATION] || '')
? "border-blue-500 focus:ring-blue-500"
: "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
/>
</div>
{launchDateCol >= 0 && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium text-slate-400 uppercase tracking-wider">{launchHeader}</label>
<input
type="text"
value={editing.launchDate}
onChange={e => setEditing(prev => prev ? { ...prev, launchDate: e.target.value } : prev)}
placeholder="DD/MM/YYYY"
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:ring-1 transition-colors",
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="text"
value={editing.readyToOrder}
onChange={e => setEditing(prev => prev ? { ...prev, readyToOrder: e.target.value } : prev)}
placeholder="DD/MM/YYYY"
className={cn(
"w-full bg-slate-900 border rounded-md px-3 py-2.5 text-sm text-white font-mono focus:outline-none focus:ring-1 transition-colors",
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>
);
}
+165
View File
@@ -0,0 +1,165 @@
import React, { useState } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter, X } from 'lucide-react';
import { cn } from '../lib/utils';
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 [search, setSearch] = useState('');
const pendingEntries = Object.entries(pendingRows);
const filteredEntries = search
? pendingEntries.filter(([articleNo, { articleName }]) =>
articleNo.toLowerCase().includes(search.toLowerCase()) ||
articleName.toLowerCase().includes(search.toLowerCase())
)
: pendingEntries;
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="flex-1 flex flex-col h-full overflow-hidden">
<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>
</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>
</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>
);
}
+221 -41
View File
@@ -10,8 +10,12 @@ import {
X, X,
ChevronDown, ChevronDown,
Edit2, Edit2,
Filter,
Check,
Search,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
interface PricingViewProps { interface PricingViewProps {
data: ExcelRow[]; data: ExcelRow[];
@@ -19,6 +23,7 @@ interface PricingViewProps {
onSaveRow: (index: number, updatedRow: ExcelRow) => void; onSaveRow: (index: number, updatedRow: ExcelRow) => void;
onCaptureState: (message: string) => void; onCaptureState: (message: string) => void;
onEdit: (index: number) => void; onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
} }
interface DetectedCol { interface DetectedCol {
@@ -41,11 +46,16 @@ function findCol(headers: string[], ...keywords: string[]): number {
); );
} }
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) { export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses }: PricingViewProps) {
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors'); const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
const [search, setSearch] = useState('');
const [editingCell, setEditingCell] = useState<EditingCell | null>(null); const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null); const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [lineMultiFilter, setLineMultiFilter] = useState<string[]>([]);
const [classificationFilter, setClassificationFilter] = useState<string[]>([]);
const [nameColFilter, setNameColFilter] = useState('');
const [openFilter, setOpenFilter] = useState<'name' | 'line' | 'classification' | null>(null);
// ── Dynamic column detection ────────────────────────────────────────────── // ── Dynamic column detection ──────────────────────────────────────────────
const { uvpIdx, srpCols, containerCols } = useMemo(() => { const { uvpIdx, srpCols, containerCols } = useMemo(() => {
@@ -127,15 +137,49 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
return { total: analyzedRows.length, withPricing, withUnits, withAny, allOk }; return { total: analyzedRows.length, withPricing, withUnits, withAny, allOk };
}, [analyzedRows]); }, [analyzedRows]);
// ── Unique values for column filters ─────────────────────────────────────
const uniqueLines = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort(),
[data]);
const uniqueClassifications = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.CLASSIFICATION] || '')))).sort(),
[data]);
// ── Filtered rows ───────────────────────────────────────────────────────── // ── Filtered rows ─────────────────────────────────────────────────────────
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
let result = analyzedRows;
// Mode filter
switch (filterMode) { switch (filterMode) {
case 'all_errors': return analyzedRows.filter(r => r.hasErrors); case 'all_errors': result = analyzedRows.filter(r => r.hasErrors); break;
case 'pricing_errors': return analyzedRows.filter(r => r.pricingErrors.length > 0); case 'pricing_errors': result = analyzedRows.filter(r => r.pricingErrors.length > 0); break;
case 'units_errors': return analyzedRows.filter(r => r.unitErrors.length > 0); case 'units_errors': result = analyzedRows.filter(r => r.unitErrors.length > 0); break;
default: return analyzedRows;
} }
}, [analyzedRows, filterMode]);
// Global search (SKU + Name)
if (search) {
const s = search.toLowerCase();
result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
);
}
// Column filters
if (nameColFilter) {
const s = nameColFilter.toLowerCase();
result = result.filter(r => String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s));
}
if (lineMultiFilter.length > 0) {
result = result.filter(r => lineMultiFilter.includes(String(r.row[COLUMNS.LINE] || '')));
}
if (classificationFilter.length > 0) {
result = result.filter(r => classificationFilter.includes(String(r.row[COLUMNS.CLASSIFICATION] || '')));
}
return result;
}, [analyzedRows, filterMode, search, nameColFilter, lineMultiFilter, classificationFilter]);
// ── Inline edit helpers ─────────────────────────────────────────────────── // ── Inline edit helpers ───────────────────────────────────────────────────
const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => { const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => {
@@ -228,6 +272,56 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
return ( return (
<div className="flex flex-col h-full gap-4"> <div className="flex flex-col h-full gap-4">
<div className="flex items-center justify-between gap-4">
{/* ── Search bar ── */}
<div className="flex-1 max-w-md relative">
<input
type="text"
placeholder="Search SKU or Name..."
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-4 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
/>
{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 className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
<Package className="w-4 h-4" />
</div>
)}
</div>
{/* ── Filter tabs ── */}
<div className="flex items-center gap-1 bg-slate-800/60 rounded-lg p-1 border border-slate-700/50 w-fit">
{FILTERS.map(f => (
<button
key={f.id}
onClick={() => setFilterMode(f.id)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
filterMode === f.id
? 'bg-slate-700 text-white shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
)}
>
{f.label}
<span className={cn(
'text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[22px] text-center',
filterMode === f.id
? 'bg-slate-600 text-white'
: f.count > 0 ? `${f.color} bg-current/10` : 'text-slate-500'
)}>
{f.count}
</span>
</button>
))}
</div>
</div>
{/* ── Column detection warning ── */} {/* ── Column detection warning ── */}
{missingCols.length > 0 && ( {missingCols.length > 0 && (
@@ -248,32 +342,6 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
<StatCard label="All OK" value={stats.allOk} icon={<CheckCircle2 className="w-4 h-4" />} color="emerald" /> <StatCard label="All OK" value={stats.allOk} icon={<CheckCircle2 className="w-4 h-4" />} color="emerald" />
</div> </div>
{/* ── Filter tabs ── */}
<div className="flex items-center gap-1 bg-slate-800/60 rounded-lg p-1 border border-slate-700/50 w-fit">
{FILTERS.map(f => (
<button
key={f.id}
onClick={() => setFilterMode(f.id)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
filterMode === f.id
? 'bg-slate-700 text-white shadow-sm'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
)}
>
{f.label}
<span className={cn(
'text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[22px] text-center',
filterMode === f.id
? 'bg-slate-600 text-white'
: f.count > 0 ? `${f.color} bg-current/10` : 'text-slate-500'
)}>
{f.count}
</span>
</button>
))}
</div>
{/* ── Table ── */} {/* ── Table ── */}
<div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl"> <div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl">
{filteredRows.length === 0 ? ( {filteredRows.length === 0 ? (
@@ -289,11 +357,77 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap"> <th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap">
Art. No. Art. No.
</th> </th>
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700"> {/* Article Name with text filter */}
Article Name <th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 group relative">
<div className="flex items-center gap-1">
<span>Article Name</span>
<button
onClick={() => setOpenFilter(openFilter === 'name' ? null : 'name')}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors',
nameColFilter ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
<Filter className="w-3 h-3" />
</button>
</div>
{openFilter === 'name' && (
<TextFilterPopover
value={nameColFilter}
onChange={setNameColFilter}
onClose={() => setOpenFilter(null)}
/>
)}
</th> </th>
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap"> {/* Line with multi-select filter */}
Line <th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap group relative">
<div className="flex items-center gap-1">
<span>Line</span>
<button
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors',
lineMultiFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
<Filter className="w-3 h-3" />
</button>
</div>
{openFilter === 'line' && (
<ColumnFilterPopover
uniqueValues={uniqueLines}
selectedValues={lineMultiFilter}
onToggle={val => setLineMultiFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
onSelectAll={vals => setLineMultiFilter(vals)}
onClear={() => { setLineMultiFilter([]); setOpenFilter(null); }}
onClose={() => setOpenFilter(null)}
/>
)}
</th>
{/* Classification with multi-select filter */}
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap group relative">
<div className="flex items-center gap-1">
<span>Classification</span>
<button
onClick={() => setOpenFilter(openFilter === 'classification' ? null : 'classification')}
className={cn(
'p-0.5 rounded hover:bg-slate-700 transition-colors',
classificationFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
)}
>
<Filter className="w-3 h-3" />
</button>
</div>
{openFilter === 'classification' && (
<ColumnFilterPopover
uniqueValues={uniqueClassifications}
selectedValues={classificationFilter}
onToggle={val => setClassificationFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
onSelectAll={vals => setClassificationFilter(vals)}
onClear={() => { setClassificationFilter([]); setOpenFilter(null); }}
onClose={() => setOpenFilter(null)}
/>
)}
</th> </th>
{/* Editable pricing columns */} {/* Editable pricing columns */}
@@ -330,16 +464,21 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
<tbody> <tbody>
{filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => { {filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => {
const hasAnyError = pricingErrors.length > 0 || unitErrors.length > 0; const hasAnyError = pricingErrors.length > 0 || unitErrors.length > 0;
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
return ( return (
<tr <tr
key={dataIndex} key={dataIndex}
className={cn( className={cn(
'border-b border-slate-700/50 transition-colors hover:bg-slate-700/20', 'border-b border-slate-700/50 transition-colors hover:bg-slate-700/20',
isCritical saveStatus === 'error'
? 'bg-red-950/20' ? 'bg-red-400/20 border-l-4 border-l-red-500'
: pricingErrors.length > 0 : saveStatus === 'pending'
? 'bg-amber-950/10' ? 'bg-yellow-400/20 border-l-4 border-l-yellow-400'
: '' : isCritical
? 'bg-red-950/20'
: pricingErrors.length > 0
? 'bg-amber-950/10'
: ''
)} )}
> >
{/* Article No */} {/* Article No */}
@@ -359,6 +498,20 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
{row[COLUMNS.LINE] || '—'} {row[COLUMNS.LINE] || '—'}
</td> </td>
{/* Classification */}
<td className="px-3 py-2.5">
<span className={cn(
'px-2 py-0.5 rounded text-[10px] font-bold border whitespace-nowrap',
String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().includes('CORE')
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
: String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().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>
</td>
{/* Editable pricing cells */} {/* Editable pricing cells */}
{pricingEditableCols.map(col => { {pricingEditableCols.map(col => {
const isEditing = editingCell?.rowIndex === dataIndex && editingCell?.colIndex === col.index; const isEditing = editingCell?.rowIndex === dataIndex && editingCell?.colIndex === col.index;
@@ -484,6 +637,33 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
); );
} }
// ── Text filter popover ───────────────────────────────────────────────────────
function TextFilterPopover({ value, onChange, onClose }: {
value: string;
onChange: (v: string) => void;
onClose: () => void;
}) {
return (
<div className="absolute top-full left-0 mt-1 w-56 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">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
<input
type="text"
placeholder="Search article name…"
value={value}
onChange={e => onChange(e.target.value)}
autoFocus
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"
/>
</div>
<div className="flex items-center justify-between pt-1 border-t border-slate-700">
<button onClick={() => { onChange(''); onClose(); }} className="text-[10px] font-medium text-slate-400 hover:text-white transition-colors">Clear</button>
<button onClick={onClose} className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded transition-colors">OK</button>
</div>
</div>
);
}
// ── Stat card ───────────────────────────────────────────────────────────────── // ── Stat card ─────────────────────────────────────────────────────────────────
function StatCard({ function StatCard({
label, label,
+236 -48
View File
@@ -1,24 +1,45 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types'; import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp } from 'lucide-react'; import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ColumnFilterPopover } from './ColumnFilterPopover';
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>;
} }
type TabType = 'all' | 'missingDeLong' | 'missingEnLong' | 'missingDeShort' | 'missingEnShort' | 'complete' | 'incomplete'; type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) { // Description columns that should only have Present/Missing filters
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all'); const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState(''); const [lineFilter, setLineFilter] = useState('');
const [licenseFilter, setLicenseFilter] = useState(''); const [licenseFilter, setLicenseFilter] = 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 [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,
});
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]);
@@ -27,17 +48,24 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
let result = data.map((row, index) => ({ row, index })); let result = data.map((row, index) => ({ row, index }));
// Tab filter // Tab filter
if (activeTab === 'missingDeLong') result = result.filter(r => !r.row[COLUMNS.LONG_DE]); if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
if (activeTab === 'missingEnLong') result = result.filter(r => !r.row[COLUMNS.LONG_EN]); if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
if (activeTab === 'missingDeShort') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]); if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
if (activeTab === 'missingEnShort') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]); if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
if (activeTab === 'complete') result = result.filter(r => r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] && r.row[COLUMNS.SHORT_DE] && r.row[COLUMNS.SHORT_EN]);
if (activeTab === 'incomplete') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN] || !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]); if (activeTab === 'complete') result = result.filter(r =>
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
r.row[COLUMNS.SHORT_DE] && r.row[COLUMNS.SHORT_EN]
);
if (activeTab === 'incomplete') result = result.filter(r =>
!r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN] ||
!r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]
);
// Search filter // Search filter
if (search) { if (search) {
const s = search.toLowerCase(); const s = search.toLowerCase();
result = result.filter(r => result = result.filter(r =>
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) || String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s) String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
); );
@@ -47,6 +75,31 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter); if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter); if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
// Column-specific filters (Excel-like)
console.log('[Filter] applying columnFilters:', columnFilters, 'result count before:', result.length);
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
const col = Number(colIdx);
const vals = selectedValues as string[];
if (vals.length > 0) {
// For description columns, filter by present/missing
if (DESCRIPTION_COLUMNS.includes(col)) {
result = result.filter(r => {
const hasValue = Boolean(r.row[col]);
const shouldInclude = (vals.includes('Present') && hasValue) || (vals.includes('Missing') && !hasValue);
return shouldInclude;
});
} else {
// For other columns, use regular value matching
const before = result.length;
result = result.filter(r => {
const cellVal = String(r.row[col] ?? '').trim();
return vals.some(v => v.trim() === cellVal);
});
console.log('[Filter] col', col, 'vals', vals, 'before:', before, 'after:', result.length);
}
}
});
// Sorting // Sorting
if (sortCol !== null) { if (sortCol !== null) {
result.sort((a, b) => { result.sort((a, b) => {
@@ -57,7 +110,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;
@@ -75,6 +128,54 @@ 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) => {
// For description columns, return only 'Present' and 'Missing'
if (DESCRIPTION_COLUMNS.includes(col)) {
return ['Present', 'Missing'];
}
// For other columns, return actual unique values
const values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort();
};
const toggleColumnFilter = (col: number, value: string) => {
console.log('[Filter] toggleColumnFilter called, col:', col, 'value:', JSON.stringify(value));
setColumnFilters(prev => {
const current = prev[col] || [];
const next = current.includes(value)
? current.filter(v => v !== value)
: [...current, value];
const updated = { ...prev, [col]: next };
console.log('[Filter] new columnFilters:', updated);
return updated;
});
setPage(1);
};
const setBatchColumnFilter = (col: number, values: string[]) => {
setColumnFilters(prev => ({ ...prev, [col]: values }));
setPage(1);
};
const getRowColor = (row: ExcelRow) => { const getRowColor = (row: ExcelRow) => {
// EOL Rule: OOC Classification and 0 or negative stock (Item Available) // EOL Rule: OOC Classification and 0 or negative stock (Item Available)
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim(); const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
@@ -84,7 +185,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
return 'bg-yellow-500/10 hover:bg-yellow-500/20'; // EOL Highlight return 'bg-yellow-500/10 hover:bg-yellow-500/20'; // EOL Highlight
} }
const fields = [row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN], row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]]; const fields = [
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_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'; if (filled === 0) return 'bg-red-900/10 hover:bg-red-900/20';
@@ -92,14 +196,14 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
}; };
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 // EOL Exception: OOC and stock <= 0
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim(); const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0); const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
if (classification === 'OOC' && stock <= 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-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>;
@@ -107,10 +211,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
const tabs: { id: TabType; label: string }[] = [ const tabs: { id: TabType; label: string }[] = [
{ id: 'all', label: 'All Products' }, { id: 'all', label: 'All Products' },
{ id: 'missingDeLong', label: 'Missing DE Long' }, { id: 'missingLongDE', label: 'Missing Long DE' },
{ id: 'missingEnLong', label: 'Missing EN Long' }, { id: 'missingLongEN', label: 'Missing Long EN' },
{ id: 'missingDeShort', label: 'Missing DE Short' }, { id: 'missingShortDE', label: 'Missing Short DE' },
{ id: 'missingEnShort', label: 'Missing EN Short' }, { id: 'missingShortEN', label: 'Missing Short EN' },
{ id: 'complete', label: 'Complete' }, { id: 'complete', label: 'Complete' },
{ id: 'incomplete', label: 'Incomplete' }, { id: 'incomplete', label: 'Incomplete' },
]; ];
@@ -124,8 +228,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
onClick={() => { setActiveTab(tab.id); setPage(1); }} onClick={() => { setActiveTab(tab.id); setPage(1); }}
className={cn( className={cn(
"px-4 py-2 rounded-md text-sm font-medium transition-colors", "px-4 py-2 rounded-md text-sm font-medium transition-colors",
activeTab === tab.id activeTab === tab.id
? "bg-blue-600 text-white shadow-md" ? "bg-blue-600 text-white shadow-md"
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white" : "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
)} )}
> >
@@ -142,9 +246,29 @@ 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) && (
<button
onClick={() => {
setColumnFilters({});
setPage(1);
}}
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"
>
<X className="w-4 h-4" />
Clear All Column Filters
</button>
)}
<select <select
value={lineFilter} value={lineFilter}
onChange={e => { setLineFilter(e.target.value); setPage(1); }} onChange={e => { setLineFilter(e.target.value); setPage(1); }}
@@ -165,46 +289,109 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
<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.LONG_DE, label: 'Long DE' }, { col: COLUMNS.LONG_DE, label: 'Long DE' },
{ col: COLUMNS.LONG_EN, label: 'Long EN' }, { col: COLUMNS.LONG_EN, label: 'Long EN' },
{ col: COLUMNS.SHORT_DE, label: 'Short DE' }, { col: COLUMNS.SHORT_DE, label: 'Short DE' },
{ col: COLUMNS.SHORT_EN, label: 'Short EN' }, { col: COLUMNS.SHORT_EN, label: 'Short EN' },
].map(({ col, label }) => ( ].map(({ col, label }) => (
<th <th
key={col} key={col}
className="px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors select-none" className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
onClick={() => handleSort(col)} style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
> >
<div className="flex items-center gap-1"> <div className="flex items-center overflow-hidden">
{label} <span className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{sortCol === col && ( {label}
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" /> {sortCol === col && (
)} sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
)}
</span>
<button
onClick={(e) => {
e.stopPropagation();
setOpenFilterCol(openFilterCol === col ? null : col);
}}
className={cn(
"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"
)}
>
<Filter className="w-3.5 h-3.5" />
</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 && (
<ColumnFilterPopover
uniqueValues={getUniqueValues(col)}
selectedValues={columnFilters[col] || []}
onToggle={(val) => toggleColumnFilter(col, val)}
onSelectAll={(vals) => setBatchColumnFilter(col, vals)}
onClear={() => {
setColumnFilters(prev => {
const next = { ...prev };
delete next[col];
return next;
});
setOpenFilterCol(null);
}}
onClose={() => setOpenFilterCol(null)}
/>
)}
</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">{row[COLUMNS.ARTICLE_NO]}</td> return (
<td className="px-4 py-3 font-medium text-white max-w-[300px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td> <tr
<td className="px-4 py-3 text-slate-300">{row[COLUMNS.LINE]}</td> key={index}
<td className="px-4 py-3 text-slate-300">{row[COLUMNS.LICENSE]}</td> className={cn(
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td> "transition-colors",
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td> getRowColor(row),
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td> saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td> saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
)}
>
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-4 py-3 font-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(
"px-2 py-0.5 rounded 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>
</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" style={{ width: columnWidths[COLUMNS.LONG_EN] }}><Badge content={row[COLUMNS.LONG_EN]} 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" 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)}
@@ -215,10 +402,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>
@@ -226,7 +414,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-sm text-slate-400"> <div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-sm text-slate-400">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<span>Showing {Math.min((page - 1) * pageSize + 1, filteredData.length)} to {Math.min(page * pageSize, filteredData.length)} of {filteredData.length} entries</span> <span>Showing {Math.min((page - 1) * pageSize + 1, filteredData.length)} to {Math.min(page * pageSize, filteredData.length)} of {filteredData.length} entries</span>
+16 -5
View File
@@ -1,19 +1,30 @@
import React from 'react'; import React from 'react';
import { FileText, Table, Box, DollarSign } from 'lucide-react'; import { FileText, Table, Box, DollarSign, Package, Clock, History, AlertTriangle } from 'lucide-react';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
interface SidebarProps { interface SidebarProps {
activeModule: string; activeModule: string;
setActiveModule: (m: 'descriptions' | 'completeness' | 'matrix' | 'dimensions' | 'pricing') => void; setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data') => void;
userEmail: string;
} }
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) { export function Sidebar({ activeModule, setActiveModule, userEmail }: SidebarProps) {
const navItems = [ const isMasterUser = userEmail?.toLowerCase() === 'christian.vidal@craze-group.com';
type ModuleId = 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history' | 'missing_data';
const navItems: { id: ModuleId; label: string; icon: React.ElementType }[] = [
{ 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: '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 },
...(isMasterUser ? [
{ id: 'pending_validation' as ModuleId, label: 'Pending Validation', icon: Clock },
{ id: 'history' as ModuleId, label: 'Change History', icon: History }
] : [])
];
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">
+122 -22
View File
@@ -1,5 +1,6 @@
import React from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { Download, Database, LogOut, Undo2 } from 'lucide-react'; import { Download, LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw } from 'lucide-react';
import { cn } from '../lib/utils';
interface TopBarProps { interface TopBarProps {
stats: any; stats: any;
@@ -12,15 +13,39 @@ interface TopBarProps {
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;
} }
export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps }: TopBarProps) { export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail, onSignOut, canUndo, onUndo, undoMessage, undoSteps, pendingCount, pendingChanges, onSaveAll, onRevertRow, isSavingAll }: TopBarProps) {
const [showPending, setShowPending] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showPending) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setShowPending(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [showPending]);
// Close dropdown when all changes are saved/reverted
useEffect(() => {
if (pendingCount === 0) setShowPending(false);
}, [pendingCount]);
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="bg-[#020812] border-b border-slate-700/30 h-32 flex items-center justify-between px-10 shrink-0 z-10 shadow-2xl">
<div className="flex items-center -ml-4"> <div className="flex items-center -ml-4">
<img <img
src="/logo.png" src="/logo.png"
alt="Craze Scan" alt="Craze Scan"
className="h-24 w-auto object-contain filter brightness-110 contrast-125 hover:scale-105 transition-transform duration-300" className="h-24 w-auto object-contain filter brightness-110 contrast-125 hover:scale-105 transition-transform duration-300"
/> />
</div> </div>
@@ -55,6 +80,77 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
)} )}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="relative" ref={dropdownRef}>
{/* 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
onClick={onSaveAll}
disabled={isSavingAll || pendingCount === 0}
className={cn(
"flex items-center gap-2 px-4 py-2 text-sm font-bold transition-all text-white",
pendingCount > 0
? "bg-green-600 hover:bg-green-500 disabled:opacity-60"
: "bg-slate-700 cursor-not-allowed"
)}
>
{isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <CloudUpload className="w-4 h-4" />}
{isSavingAll
? '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-50 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>
{hasData && ( {hasData && (
<button <button
onClick={onExport} onClick={onExport}
@@ -69,22 +165,26 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
{hasUnsavedChanges && <span className="w-2 h-2 rounded-full bg-red-500 ml-1 animate-pulse" />} {hasUnsavedChanges && <span className="w-2 h-2 rounded-full bg-red-500 ml-1 animate-pulse" />}
</button> </button>
)} )}
{canUndo && ( <button
<button onClick={onUndo}
onClick={onUndo} disabled={!canUndo}
title={`Undo: ${undoMessage}`} title={canUndo ? `Undo: ${undoMessage}` : 'No changes to undo'}
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" className={cn(
> "flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all shadow-lg relative group",
<Undo2 className="w-4 h-4" /> canUndo
BACK / UNDO ? "bg-amber-600 hover:bg-amber-700 text-white shadow-amber-900/40 cursor-pointer"
{undoSteps > 1 && ( : "bg-slate-800 text-slate-600 shadow-none cursor-not-allowed opacity-50"
<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"> )}
{undoSteps} >
</span> <Undo2 className="w-4 h-4" />
)} BACK / UNDO
</button> {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 font-bold">
{undoSteps}
</span>
)}
</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">
-9
View File
@@ -1,6 +1,5 @@
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 {
@@ -9,10 +8,6 @@ export interface AuthSession {
} }
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 +24,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: {
+198 -53
View File
@@ -1,69 +1,214 @@
import { ExcelRow } 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_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) { export interface ExcelRow extends Array<any> {}
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?id=eq.${encodeURIComponent(articleNo)}`, {
method: 'PATCH',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json',
'Prefer': 'resolution=merge-duplicates'
},
body: JSON.stringify({
id: articleNo,
data: rowData,
updated_at: new Date().toISOString()
})
});
if (response.status === 204 || response.ok) { export interface SyncedRow {
// If PATCH didn't find the record, try UPSERT data: ExcelRow;
const upsertResponse = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, { status?: 'pending' | 'synced';
method: 'POST', }
headers: {
'apikey': SUPABASE_KEY, export async function getAllSyncedRows(token?: string): Promise<Record<string, SyncedRow>> {
'Authorization': `Bearer ${SUPABASE_KEY}`, try {
'Content-Type': 'application/json', const response = await fetch(
'Prefer': 'resolution=merge-duplicates' `${SUPABASE_URL}/rest/v1/products?select=product_id,data,status&order=updated_at.desc`,
}, {
body: JSON.stringify({ cache: 'no-store',
id: articleNo, headers: {
data: rowData, 'apikey': SUPABASE_KEY,
updated_at: new Date().toISOString() 'Authorization': `Bearer ${SUPABASE_KEY}`
}) }
}); }
return upsertResponse.ok; );
if (!response.ok) {
const errText = await response.text();
console.error('getAllSyncedRows failed:', response.status, errText);
return {};
} }
return true; const rows = await response.json();
const result: Record<string, SyncedRow> = {};
for (const row of rows) {
result[row.product_id] = { data: row.data, status: row.status };
}
return result;
} catch (error) { } catch (error) {
console.error('Error fetching synced rows from Supabase:', error);
return {};
}
}
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow, token?: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'resolution=merge-duplicates'
},
body: JSON.stringify({
product_id: articleNo,
data: rowData,
status: 'synced',
updated_at: new Date().toISOString()
})
}
);
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?: number;
product_id: string;
article_name: string;
old_data: ExcelRow;
new_data: ExcelRow;
changed_by: string;
changed_at: string;
}
export async function saveHistoryEntry(
productId: string,
articleName: string,
oldData: ExcelRow,
newData: ExcelRow,
changedBy: string,
token?: string
): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'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(token?: string): Promise<HistoryEntry[]> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
{
cache: 'no-store',
headers: {
'apikey': SUPABASE_KEY,
// Try using only the ANON key just in case RLS or token expiry is failing silently
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
if (!response.ok) {
const err = await response.text();
return [{
id: 'error-' + Date.now(),
product_id: 'ERROR',
article_name: `Failed: ${response.status} ${err}`,
old_data: [],
new_data: [],
changed_at: new Date().toISOString(),
changed_by: 'system'
}];
}
return await response.json();
} catch (error: any) {
console.error('Error fetching history:', error);
return [{
id: 'error-' + Date.now(),
product_id: 'EXCEPTION',
article_name: `Message: ${error.message}`,
old_data: [],
new_data: [],
changed_at: new Date().toISOString(),
changed_by: 'system'
}];
}
}
export async function deleteHistoryEntry(id: string, token?: string): Promise<boolean> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?id=eq.${encodeURIComponent(id)}`,
{
method: 'DELETE',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
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(token?: string): Promise<boolean> {
try { try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, { const response = await fetch(
headers: { `${SUPABASE_URL}/rest/v1/products?status=eq.pending`,
'apikey': SUPABASE_KEY, {
'Authorization': `Bearer ${SUPABASE_KEY}` method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Prefer': 'return=minimal'
},
body: JSON.stringify({ status: 'synced' })
} }
}); );
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 };
}
+7 -4
View File
@@ -6,6 +6,7 @@ export interface AppState {
fileName: string; fileName: string;
fileDate: Date | null; fileDate: Date | null;
hasUnsavedChanges: boolean; hasUnsavedChanges: boolean;
asinColumnIndex: number | null;
} }
export const COLUMNS = { export const COLUMNS = {
@@ -23,15 +24,17 @@ export const COLUMNS = {
SHORT_DE: 64, SHORT_DE: 64,
SHORT_EN: 65, SHORT_EN: 65,
RECOMMENDED_AGE: 67, RECOMMENDED_AGE: 67,
CLASSIFICATION: 11, // Column L (index 11) CLASSIFICATION: 11,
ITEM_AVAILABLE: 14, // Column O (index 14) ITEM_AVAILABLE: 14,
MOQ: 27, MOQ: 27,
UNITS_INNER: 31, UNITS_INNER: 31,
UNITS_OUTER: 32, UNITS_OUTER: 32,
ASIN: 33,
INNER_W: 42, INNER_W: 42,
INNER_L: 43, INNER_L: 43,
INNER_H: 44, INNER_H: 44,
OUTER_W: 47, OUTER_W: 47,
OUTER_L: 48, OUTER_L: 48,
OUTER_H: 49 OUTER_H: 49,
}; VERIFIED_DIMS: 100
};
+2 -1
View File
@@ -1,5 +1,6 @@
{ {
"rewrites": [ "rewrites": [
{ "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" } { "source": "/api/dropbox-proxy", "destination": "api/dropbox-proxy.js" },
{ "source": "/api/dropbox-sync", "destination": "api/dropbox-sync.js" }
] ]
} }