Author SHA1 Message Date
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
19 changed files with 1283 additions and 390 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 *)"
]
}
}
+10 -76
View File
@@ -15,7 +15,7 @@ npm run clean # Remove dist folder
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
- Use meaningful variable and function names
### Imports
**Order (top to bottom):**
1. React imports (`react`)
### Imports (order top to bottom)
1. React (`react`)
2. External libraries (`lucide-react`, `xlsx`, etc.)
3. Internal components (`./components/...`)
4. Internal lib/utils (`./lib/...`)
@@ -44,19 +42,16 @@ import { cn } from '../lib/utils';
```
### TypeScript Conventions
- Use explicit types for props and function parameters
- Use `any` sparingly; prefer union types or interfaces
- Define column indices in a centralized `COLUMNS` object (see `src/types.ts`)
```typescript
// Good
interface ProductDescriptionsProps {
data: ExcelRow[];
onEdit: (index: number) => void;
}
// Good - centralized constants
export const COLUMNS = {
ARTICLE_NO: 0,
ARTICLE_NAME: 2,
@@ -76,119 +71,58 @@ export const COLUMNS = {
| Types | PascalCase | `TabType`, `SortDirection` |
### React Patterns
- Destructure props in function signature
- Use `useMemo` for expensive computations
- Use `useCallback` for event handlers passed to child components
- 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
- Use TypeScript's type system for runtime safety
- Use optional chaining (`?.`) and nullish coalescing (`??`)
- 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
- Use Tailwind CSS for all styling
- Use `cn()` utility from `lib/utils` for conditional classes
- Follow existing color scheme (slate, blue, green, red for status)
- Use `lucide-react` for icons
- Keep responsive design in mind
```typescript
import { cn } from '../lib/utils';
<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"
)}
>
<button className={cn("px-4 py-2 rounded-md", isActive ? "bg-blue-600" : "bg-slate-800")}>
```
### File Organization
```
src/
├── components/ # React components
│ ├── Sidebar.tsx
│ ├── MatrixView.tsx
│ └── ...
├── lib/ # Utilities and helpers
│ ├── utils.ts # cn(), helpers
│ ├── auth.ts # Authentication
│ └── supabase.ts # Database operations
├── services/ # External API integrations
│ ├── gemini.ts
│ └── anthropic.ts
├── lib/ # Utilities (utils.ts, auth.ts, supabase.ts)
├── services/ # External API integrations (gemini.ts, anthropic.ts)
├── types.ts # TypeScript types and constants
├── App.tsx # Main application
└── main.tsx # Entry point
```
### Data Processing
- When processing Excel data, handle both string and number types
- Handle both string and number types when processing Excel data
- Use centralized column index constants
- 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
// Handle date columns from Excel
if (header.includes('date') || header.includes('launch')) {
if (typeof val === 'number' && val >= 25569 && val <= 60000) {
const excelEpoch = new Date(1899, 11, 30);
const date = new Date(excelEpoch.getTime() + val * 86400000);
return date.toLocaleDateString('en-GB');
}
return new Date(excelEpoch.getTime() + val * 86400000).toLocaleDateString('en-GB');
}
```
### Git Workflow
- Make small, focused commits
- Write clear commit messages describing what changed
- 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
- Use `.env` file for local development
- Never commit secrets - use Vercel dashboard for production env vars
Required environment variables (production):
- `VITE_SUPABASE_URL`
- `VITE_SUPABASE_ANON_KEY`
- `VITE_GEMINI_API_KEY`
- `VITE_ANTHROPIC_API_KEY`
Required: `VITE_SUPABASE_URL`, `VITE_SUPABASE_ANON_KEY`, `VITE_GEMINI_API_KEY`, `VITE_ANTHROPIC_API_KEY`
+163 -41
View File
@@ -6,38 +6,44 @@ import { TopBar } from './components/TopBar';
import { ProductDescriptions } from './components/ProductDescriptions';
import { MatrixView } from './components/MatrixView';
import { EditPanel } from './components/EditPanel';
import { getAllSyncedRows, saveRowToSupabase } from './lib/supabase';
import { getAllSyncedRows, saveRowToSupabase, resetAllPendingRows, saveHistoryEntry } from './lib/supabase';
import { getStoredSession, signOut, type AuthSession } from './lib/auth';
import { LoginPage } from './components/LoginPage';
import { DimensionsView } from './components/DimensionsView';
import { PricingView } from './components/PricingView';
import { ArticleDetails } from './components/ArticleDetails';
import { HistoryView } from './components/HistoryView';
import { UndoToast } from './components/UndoToast';
import { PendingValidationView } from './components/PendingValidationView';
export default function App() {
const [session, setSession] = useState<AuthSession | null>(() => getStoredSession());
const handleSignOut = () => {
signOut();
setSession(null);
};
if (!session) {
return <LoginPage onLogin={() => setSession(getStoredSession())} />;
}
const [appState, setAppState] = useState<AppState>({
headers: [],
data: [],
fileName: '',
fileDate: null,
hasUnsavedChanges: false
hasUnsavedChanges: false,
asinColumnIndex: null
});
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing'>('descriptions');
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history'>('descriptions');
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
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(() => {
const loadDefaultData = async () => {
@@ -72,13 +78,27 @@ export default function App() {
const rawHeaders = data[0];
const rawRows = data.slice(1);
// Find ASIN column index from headers (case insensitive)
const asinIdx = (rawHeaders as string[]).findIndex((h: string) =>
String(h).toLowerCase().trim() === 'asin'
);
if (asinIdx !== -1) {
console.log('ASIN column found at index:', asinIdx);
}
console.log('Applying Supabase overrides...');
const syncedData = await getAllSyncedRows();
const articleNoIdx = COLUMNS.ARTICLE_NO;
const processedRows = rawRows.map(row => {
const articleNo = String(row[articleNoIdx]);
const finalRow = syncedData[articleNo] || row;
const synced = syncedData[articleNo];
const finalRow = synced ? synced.data : row;
// Sync status_check
if (synced && synced.status === 'pending') {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'pending' }));
}
// Format numeric/price fields to 2 decimal places
return finalRow.map((val, idx) => {
@@ -116,7 +136,8 @@ export default function App() {
data: processedRows,
fileName: 'Data-Matrix.xlsx (Cloud Sync)',
fileDate: new Date(),
hasUnsavedChanges: false
hasUnsavedChanges: false,
asinColumnIndex: asinIdx !== -1 ? asinIdx : null
});
setActiveModule('descriptions');
}
@@ -128,8 +149,8 @@ export default function App() {
}
};
loadDefaultData();
}, []);
if (session) loadDefaultData();
}, [session]);
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
@@ -200,7 +221,8 @@ export default function App() {
data: processedRows,
fileName: file.name,
fileDate: new Date(),
hasUnsavedChanges: false
hasUnsavedChanges: false,
asinColumnIndex: null
});
setActiveModule('descriptions');
}
@@ -219,34 +241,73 @@ export default function App() {
const dateStr = new Date().toISOString().split('T')[0];
XLSX.writeFile(wb, `CRAZE_Products_Updated_${dateStr}.xlsx`);
// 3. Post-export: Reset pending statuses in Supabase
console.log('Resetting pending statuses in Supabase...');
resetAllPendingRows().then(success => {
if (success) {
console.log('Successfully reset all pending statuses');
setRowStatuses({}); // Clear local statuses
}
});
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
};
const handleSaveRow = async (rowIndex: number, updatedRow: ExcelRow) => {
// 1. Update UI state
const handleSaveRow = (rowIndex: number, updatedRow: ExcelRow) => {
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
const originalData = appState.data[rowIndex]; // Capture before update
setAppState(prev => {
const newData = [...prev.data];
newData[rowIndex] = updatedRow;
return {
...prev,
data: newData,
hasUnsavedChanges: true
};
return { ...prev, data: newData, hasUnsavedChanges: true };
});
setEditingRowIndex(null);
// 2. Persist to Supabase
const articleNo = String(updatedRow[COLUMNS.ARTICLE_NO]);
console.log(`Saving article ${articleNo} to Supabase...`);
const success = await saveRowToSupabase(articleNo, updatedRow);
if (success) {
console.log(`Successfully saved ${articleNo}`);
setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
} else {
console.error(`Failed to save ${articleNo} to Supabase`);
alert("Error saving to database. Local changes will be lost on refresh if not saved.");
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);
};
const handleRevertRow = (articleNo: string) => {
const pending = pendingRows[articleNo];
if (!pending) return;
setAppState(prev => {
const newData = [...prev.data];
newData[pending.rowIndex] = pending.originalData;
const stillPending = Object.keys(pendingRows).length > 1;
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 () => {
const entries = Object.entries(pendingRows) as [string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }][];
if (entries.length === 0) return;
setIsSavingAll(true);
let allSuccess = true;
for (const [articleNo, { newData, originalData, articleName }] of entries) {
const success = await saveRowToSupabase(articleNo, newData);
if (success) {
// Also save to history
await saveHistoryEntry(articleNo, articleName, originalData, newData, session?.user?.email || 'unknown');
setRowStatuses(prev => ({ ...prev, [articleNo]: 'saved' }));
setPendingRows(prev => { const n = { ...prev }; delete n[articleNo]; return n; });
} else {
setRowStatuses(prev => ({ ...prev, [articleNo]: 'error' }));
allSuccess = false;
}
}
if (allSuccess) setAppState(prev => ({ ...prev, hasUnsavedChanges: false }));
setIsSavingAll(false);
};
const captureState = (message: string) => {
@@ -255,8 +316,8 @@ export default function App() {
data: JSON.parse(JSON.stringify(appState.data)), // Deep copy
message
};
// Keep only last 5 steps
const newHistory = [newState, ...prev].slice(0, 5);
// Keep last 50 steps
const newHistory = [newState, ...prev].slice(0, 50);
return newHistory;
});
};
@@ -305,6 +366,14 @@ export default function App() {
};
}, [appState.data]);
if (!session) {
return <LoginPage onLogin={() => {
const stored = getStoredSession();
console.log('[App] onLogin, stored session:', stored ? 'found' : 'null');
setSession(stored);
}} />;
}
return (
<div className="h-screen bg-[#040d1a] text-slate-200 flex flex-col font-sans overflow-hidden">
<TopBar
@@ -318,6 +387,11 @@ export default function App() {
onUndo={handleUndo}
undoMessage={undoHistory[0]?.message}
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">
<Sidebar activeModule={activeModule} setActiveModule={setActiveModule} />
@@ -348,11 +422,14 @@ export default function App() {
{activeModule === 'descriptions' && (
<ProductDescriptions
data={appState.data}
headers={appState.headers}
asinColumnIndex={appState.asinColumnIndex}
onEdit={(index) => setEditingRowIndex(index)}
rowStatuses={rowStatuses}
/>
)}
{activeModule === 'matrix' && (
<MatrixView data={appState.data} headers={appState.headers} />
<MatrixView data={appState.data} headers={appState.headers} rowStatuses={rowStatuses} />
)}
{activeModule === 'dimensions' && (
@@ -362,6 +439,8 @@ export default function App() {
onEdit={(index) => setEditingRowIndex(index)}
onSaveRow={handleSaveRow}
onCaptureState={captureState}
rowStatuses={rowStatuses}
onRevertRow={handleRevertRow}
/>
)}
@@ -372,12 +451,50 @@ export default function App() {
onSaveRow={handleSaveRow}
onCaptureState={captureState}
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 === 'history' && (
<HistoryView
headers={appState.headers}
onRevert={(articleNo, revertedData) => {
// 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),
}
}));
}
}}
/>
)}
</>
@@ -388,7 +505,12 @@ export default function App() {
<UndoToast
undoState={undoHistory[0] || null}
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 && (
+65 -20
View File
@@ -7,11 +7,12 @@ 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 }: ArticleDetailsProps) {
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState('');
@@ -21,6 +22,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
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]);
@@ -70,7 +79,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
}
return result;
}, [data, activeTab, search, lineFilter, sortCol, sortDesc]);
}, [data, activeTab, search, lineFilter, columnFilters, sortCol, sortDesc]);
const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize;
@@ -88,6 +97,25 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
}
};
const handleResize = (colIndex: number, e: React.MouseEvent) => {
e.preventDefault();
const startX = e.pageX;
const startWidth = columnWidths[colIndex] || 100;
const onMouseMove = (moveEvent: MouseEvent) => {
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const getUniqueValues = (col: number) => {
const values = data.map(r => String(r[col] || ''));
return Array.from(new Set(values)).sort();
@@ -184,7 +212,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
<div className="overflow-x-auto flex-1">
<table className="w-full text-left text-xs">
<table className="w-full text-left text-xs" style={{ tableLayout: 'fixed' }}>
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
<tr>
{[
@@ -197,13 +225,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
].map(({ col, label }) => (
<th
key={col}
className="px-3 py-3 font-medium transition-colors select-none group relative"
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
>
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
<div className="flex items-center justify-between gap-1 overflow-hidden">
<div 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" />
sortDesc ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronUp className="w-3 h-3 shrink-0" />
)}
</div>
<button
@@ -212,7 +241,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
setOpenFilterCol(openFilterCol === col ? null : col);
}}
className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors",
"p-1 rounded hover:bg-slate-700 transition-colors shrink-0",
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
)}
>
@@ -220,6 +249,12 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
</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)}
@@ -239,17 +274,26 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
)}
</th>
))}
<th className="px-3 py-3 font-medium text-right">Edit</th>
<th className="px-3 py-3 font-medium text-right" style={{ width: 80 }}>Edit</th>
</tr>
</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">{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-3 py-2 font-medium text-slate-200 max-w-[150px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>
{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">
<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"
@@ -257,7 +301,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
{row[COLUMNS.CLASSIFICATION] || '—'}
</span>
</td>
<td className="px-3 py-2">
<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"
@@ -265,14 +309,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
{row[COLUMNS.ITEM_AVAILABLE] || 0}
</span>
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
{row[COLUMNS.DETAILS_DE] ? (
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
) : getBadge(null)}
</td>
<td className="px-3 py-2">
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
{row[COLUMNS.DETAILS_EN] ? (
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
) : getBadge(null)}
</td>
<td className="px-3 py-2 text-right">
@@ -284,7 +328,8 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
</button>
</td>
</tr>
))}
);
})}
{paginatedData.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
+16 -11
View File
@@ -1,5 +1,5 @@
import React, { useState, useMemo } from 'react';
import { Search, Check, X } from 'lucide-react';
import { Search, Check } from 'lucide-react';
import { cn } from '../lib/utils';
interface ColumnFilterPopoverProps {
@@ -34,10 +34,13 @@ export function ColumnFilterPopover({
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
return (
<div className={cn(
<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">
@@ -54,21 +57,23 @@ export function ColumnFilterPopover({
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
{filteredValues.map(val => (
<label key={val} className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
<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>
<input
type="checkbox"
className="hidden"
checked={selectedValues.includes(val)}
onChange={() => onToggle(val)}
/>
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
</label>
</div>
))}
{filteredValues.length === 0 && (
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
+63 -32
View File
@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon } from 'lucide-react';
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon, Undo2 } from 'lucide-react';
import { cn } from '../lib/utils';
import { ConfirmModal } from './ConfirmModal';
import { ColumnFilterPopover } from './ColumnFilterPopover';
@@ -11,6 +11,8 @@ interface DimensionsViewProps {
onEdit: (index: number) => void;
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
onCaptureState: (message: string) => void;
rowStatuses: Record<string, string>;
onRevertRow: (articleNo: string) => void;
}
interface DimensionGroup {
@@ -32,7 +34,7 @@ interface NearDuplicateCluster {
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 [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
@@ -45,7 +47,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
clusterIndex: number;
clusterKey: string;
targetGroupKey: string;
selectedIndices: number[];
} | null>(null);
@@ -119,7 +121,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
const filteredGroups = useMemo(() => {
let result = groups;
if (showOnlyInconsistent) result = result.filter(g => g.isInconsistent);
if (showOnlyInconsistent) {
result = result.filter(g =>
g.isInconsistent ||
g.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending')
);
}
if (search || lineFilter.length > 0 || classFilter.length > 0) {
const s = search.toLowerCase();
@@ -136,7 +143,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
}
return result;
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter]);
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter, rowStatuses]);
const uniqueLines = useMemo(() =>
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
@@ -216,9 +223,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
const executeNearDupSync = async () => {
if (!pendingNearDupSync) return;
const { clusterIndex, targetGroupKey, selectedIndices } = pendingNearDupSync;
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
const cluster = nearDuplicateClusters[clusterIndex];
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
if (!cluster) return;
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
if (!targetGroup) return;
@@ -241,7 +249,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
setClusterSelections(prev => {
const next = { ...prev };
delete next[clusterIndex];
delete next[pendingNearDupSync.clusterKey];
return next;
});
};
@@ -394,11 +402,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</div>
{nearDuplicateClusters.map((cluster, ci) => {
const allClusterRows = cluster.groups.flatMap(g => g.rows);
const selection = clusterSelections[ci] ?? new Set<number>();
const targetKey = clusterSyncTargets[ci] ?? cluster.groups[0].key;
const clusterKey = cluster.groups[0].key;
const selection = clusterSelections[clusterKey] ?? new Set<number>();
const targetKey = clusterSyncTargets[clusterKey] ?? cluster.groups[0].key;
return (
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
<div key={clusterKey} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
<button
onClick={() => {
const next = new Set(expandedNearDuplicates);
@@ -422,12 +431,11 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
{expandedNearDuplicates.has(ci) && (
<div className="border-t border-violet-500/20">
{/* Sync toolbar */}
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
<span className="text-xs text-slate-400">Sync selected to:</span>
<select
value={targetKey}
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [ci]: e.target.value }))}
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [clusterKey]: e.target.value }))}
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
>
{cluster.groups.map(g => {
@@ -441,21 +449,21 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</select>
<button
disabled={selection.size === 0}
onClick={() => setPendingNearDupSync({ clusterIndex: ci, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
onClick={() => setPendingNearDupSync({ clusterKey, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<Layers className="w-3 h-3" />
Sync {selection.size} selected
</button>
<button
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set(allClusterRows.map(r => r.index)) }))}
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set(allClusterRows.map(r => r.index)) }))}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Select all
</button>
{selection.size > 0 && (
<button
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set() }))}
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set() }))}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
>
Clear
@@ -463,7 +471,6 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
)}
</div>
{/* Flat product list */}
<div className="divide-y divide-violet-500/10">
{allClusterRows.map(({ row, index }) => {
const isSelected = selection.has(index);
@@ -475,17 +482,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
isSelected && "bg-violet-500/10"
)}
onClick={() => setClusterSelections(prev => {
const current = new Set(prev[ci] ?? []);
const current = new Set(prev[clusterKey] ?? []);
if (current.has(index)) current.delete(index); else current.add(index);
return { ...prev, [ci]: current };
return { ...prev, [clusterKey]: current };
})}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
onClick={e => e.stopPropagation()}
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0"
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">
@@ -509,9 +515,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
)}
<div className="space-y-3">
{filteredGroups.map(group => (
{filteredGroups.map(group => {
const hasPending = group.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending');
return (
<div key={group.key} className={cn(
"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">
@@ -525,17 +534,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">
Inner: {group.innerDims} cm
</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 ? (
<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" />
Inconsistent
</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">
<CheckCircle2 className="w-3 h-3" />
Consistent
</span>
)}
) : null}
</div>
<div className="text-xs text-slate-500 mt-1">
{group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group
@@ -584,8 +598,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</tr>
</thead>
<tbody className="divide-y divide-slate-700/50">
{group.rows.map(({ row, index }) => (
<tr key={index} className="hover:bg-slate-700/20 group transition-colors">
{group.rows.map(({ row, index }) => {
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">
<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>
@@ -642,32 +664,41 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
</div>
</td>
<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
onClick={() => handleFullSync(group, row)}
title="FULL SYNC: Apply ALL packaging measures labels to all in group"
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" />}
</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
onClick={() => onEdit(index)}
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" />
</button>
</div>
</td>
</tr>
))}
);
})}
</tbody>
</table>
</div>
)}
</div>
))}
);
})}
{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">
+160 -73
View File
@@ -13,6 +13,96 @@ interface EditPanelProps {
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;
}
const FieldEditor = ({
title,
field,
value,
isModified,
canTranslate,
isGenerated,
isLoading,
onGenerate,
onChange,
onKeyDown
}: 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>
)}
<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) {
const [formData, setFormData] = useState({
longDe: row[COLUMNS.LONG_DE] || '',
@@ -74,7 +164,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
let prompt = '';
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. 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 (formData.longEn) {
@@ -84,6 +174,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
2. Maintain the EXACT same length and detailed information.
3. Keep all technical specs intact.
4. Translate every single paragraph into natural, commercial German for toy buyers.
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
English Text to Translate:
${formData.longEn}`;
@@ -98,6 +189,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
2. Maintain the EXACT same length and detailed information.
3. Keep all technical specs intact.
4. Translate every single paragraph into natural, commercial English for toy buyers.
5. Translate ALL text, including titles or phrases in ALL CAPS, and keep them in ALL CAPS in the translation.
German Text to Translate:
${formData.longDe}`;
@@ -106,7 +198,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
}
} else if (field === 'shortDe') {
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) {
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}`;
@@ -115,7 +207,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
}
} else if (field === 'shortEn') {
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) {
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}`;
@@ -127,6 +219,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
const generatedText = await generateGemini(prompt, systemPrompt);
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) {
setError(err.message || 'An error occurred during generation.');
} finally {
@@ -158,63 +255,9 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
onSave(rowIndex, newRow);
};
const DimensionInput = ({ label, field, placeholder }: { label: string, field: keyof typeof formData, placeholder?: string }) => (
<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={formData[field]}
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
placeholder={placeholder}
className={cn(
"w-full bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors",
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500"
)}
/>
</div>
);
const FieldEditor = ({ title, field }: { title: string, field: keyof typeof formData }) => (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-slate-300">{title}</label>
<div className="flex items-center gap-2">
{((field === 'longDe' && formData.longEn) ||
(field === 'longEn' && formData.longDe) ||
(field === 'shortDe' && (formData.longDe || formData.shortEn)) ||
(field === 'shortEn' && (formData.shortDe || formData.longEn))) && (
<div className="flex items-center gap-1 text-[10px] text-slate-500 italic">
<Languages className="w-3 h-3" />
Can translate from existing
</div>
)}
<button
onClick={() => handleGenerate(field)}
disabled={loadingField !== null}
className="flex items-center gap-1.5 text-xs font-medium bg-blue-600/20 text-blue-400 hover:bg-blue-600 hover:text-white px-2 py-1 rounded transition-colors disabled:opacity-50"
>
{loadingField === field ? <Loader2 className="w-3 h-3 animate-spin" /> : <Sparkles className="w-3 h-3" />}
{((field === 'longDe' && formData.longEn) || (field === 'longEn' && formData.longDe) || (field === 'shortDe' && formData.shortEn) || (field === 'shortEn' && formData.shortDe)) ? 'Translate with Gemini' : 'Generate with Gemini'}
</button>
</div>
{generatedFields.has(field) && (
<div className="flex items-center gap-1.5 text-[10px] text-emerald-400 bg-emerald-400/10 px-2 py-0.5 rounded w-fit animate-in fade-in slide-in-from-top-1 duration-300">
<CheckCircle2 className="w-3 h-3" />
AI Generated - You can still edit manually
</div>
)}
</div>
<textarea
value={formData[field]}
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
className={cn(
"w-full h-32 bg-slate-900 border rounded-md p-3 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
isModified(field) ? "border-blue-500 focus:ring-blue-500" : "border-slate-700 focus:border-slate-500 focus:ring-slate-500"
)}
placeholder={`Enter ${title}...`}
/>
</div>
);
const handleInputKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
};
return (
<>
@@ -279,28 +322,72 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
<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>
<DimensionInput label="Length" field="innerL" placeholder="L" />
<DimensionInput label="Width" field="innerW" placeholder="W" />
<DimensionInput label="Height" field="innerH" placeholder="H" />
<DimensionInput label="Length" value={formData.innerL} field="innerL" isModified={isModified('innerL')} onChange={(val) => setFormData(p => ({...p, innerL: val}))} placeholder="L" />
<DimensionInput label="Width" value={formData.innerW} field="innerW" isModified={isModified('innerW')} onChange={(val) => setFormData(p => ({...p, innerW: val}))} placeholder="W" />
<DimensionInput label="Height" value={formData.innerH} field="innerH" isModified={isModified('innerH')} onChange={(val) => setFormData(p => ({...p, innerH: val}))} placeholder="H" />
</div>
<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>
<DimensionInput label="Length" field="outerL" placeholder="L" />
<DimensionInput label="Width" field="outerW" placeholder="W" />
<DimensionInput label="Height" field="outerH" placeholder="H" />
<DimensionInput label="Length" value={formData.outerL} field="outerL" isModified={isModified('outerL')} onChange={(val) => setFormData(p => ({...p, outerL: val}))} placeholder="L" />
<DimensionInput label="Width" value={formData.outerW} field="outerW" isModified={isModified('outerW')} onChange={(val) => setFormData(p => ({...p, outerW: val}))} placeholder="W" />
<DimensionInput label="Height" value={formData.outerH} field="outerH" isModified={isModified('outerH')} onChange={(val) => setFormData(p => ({...p, outerH: val}))} placeholder="H" />
</div>
<div className="grid grid-cols-2 gap-3">
<DimensionInput label="Units per Outer" field="unitsOuter" />
<DimensionInput label="MOQ" field="moq" />
<DimensionInput label="Units per Outer" value={formData.unitsOuter} field="unitsOuter" isModified={isModified('unitsOuter')} onChange={(val) => setFormData(p => ({...p, unitsOuter: val}))} />
<DimensionInput label="MOQ" value={formData.moq} field="moq" isModified={isModified('moq')} onChange={(val) => setFormData(p => ({...p, moq: val}))} />
</div>
</div>
<FieldEditor title="Long Description (DE)" field="longDe" />
<FieldEditor title="Long Description (EN)" field="longEn" />
<FieldEditor title="Short Description (DE)" field="shortDe" />
<FieldEditor title="Short Description (EN)" field="shortEn" />
<FieldEditor
title="Long Description (DE)"
field="longDe"
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}
/>
<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}
/>
<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 className="p-6 border-t border-slate-700 bg-slate-800/50 flex justify-end gap-3">
@@ -315,7 +402,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"
>
<Save className="w-4 h-4" />
Save to Memory
Queue Changes
</button>
</div>
<ConfirmModal
+224
View File
@@ -0,0 +1,224 @@
import React, { useState, useEffect } from 'react';
import { History, RotateCcw, ChevronDown, ChevronRight, User, Calendar, Tag } from 'lucide-react';
import { getHistory, HistoryEntry } from '../lib/supabase';
import { ExcelRow, COLUMNS } from '../types';
import { cn } from '../lib/utils';
interface HistoryViewProps {
headers: string[];
onRevert: (articleNo: string, oldData: ExcelRow) => void;
}
export function HistoryView({ headers, onRevert }: 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();
setHistory(data);
setLoading(false);
};
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-4 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"
/>
</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 (window.confirm(`Are you sure you want to revert changes for ${entry.article_name}?`)) {
onRevert(entry.product_id, entry.old_data);
}
}}
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"
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@craze-group.com"
placeholder="you@example.com"
required
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"
@@ -157,7 +157,7 @@ export function LoginPage({ onLogin }: LoginPageProps) {
</form>
<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>
</div>
</div>
+2 -1
View File
@@ -7,9 +7,10 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
interface MatrixViewProps {
data: ExcelRow[];
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 [pageSize, setPageSize] = useState(25);
const [search, setSearch] = useState('');
+157
View File
@@ -0,0 +1,157 @@
import React, { useState } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Clock, Undo2, Package, Box, DollarSign, FileText, Search, Filter } 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-3 py-2 text-sm text-white placeholder:text-slate-500 focus:outline-none focus:border-blue-500 w-64"
/>
</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>
);
}
+8 -2
View File
@@ -23,6 +23,7 @@ interface PricingViewProps {
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
onCaptureState: (message: string) => void;
onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
}
interface DetectedCol {
@@ -45,7 +46,7 @@ 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 [search, setSearch] = useState('');
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
@@ -454,12 +455,17 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
<tbody>
{filteredRows.map(({ row, dataIndex, pricingErrors, unitErrors, isCritical }) => {
const hasAnyError = pricingErrors.length > 0 || unitErrors.length > 0;
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
return (
<tr
key={dataIndex}
className={cn(
'border-b border-slate-700/50 transition-colors hover:bg-slate-700/20',
isCritical
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'
: isCritical
? 'bg-red-950/20'
: pricingErrors.length > 0
? 'bg-amber-950/10'
+92 -31
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo, useCallback } from 'react';
import React, { useState, useMemo } from 'react';
import { ExcelRow, COLUMNS } from '../types';
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
import { cn } from '../lib/utils';
@@ -6,15 +6,18 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
interface ProductDescriptionsProps {
data: ExcelRow[];
headers: string[];
asinColumnIndex: number | null;
onEdit: (index: number) => void;
rowStatuses: Record<string, string>;
}
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingLongAny' | 'missingShortDE' | 'missingShortEN' | 'missingShortAny' | 'complete' | 'incomplete';
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
// 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, onEdit }: ProductDescriptionsProps) {
export function ProductDescriptions({ data, headers, asinColumnIndex, onEdit, rowStatuses }: ProductDescriptionsProps) {
const [activeTab, setActiveTab] = useState<TabType>('all');
const [search, setSearch] = useState('');
const [lineFilter, setLineFilter] = useState('');
@@ -25,6 +28,18 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
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]: 250,
[COLUMNS.ASIN]: asinColumnIndex !== null ? 150 : 0,
[COLUMNS.LINE]: 80,
[COLUMNS.LICENSE]: 120,
[COLUMNS.CLASSIFICATION]: 100,
[COLUMNS.LONG_DE]: 80,
[COLUMNS.LONG_EN]: 80,
[COLUMNS.SHORT_DE]: 80,
[COLUMNS.SHORT_EN]: 80,
});
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]);
@@ -35,10 +50,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
// Tab filter
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
if (activeTab === 'missingLongAny') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN]);
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
if (activeTab === 'missingShortAny') result = result.filter(r => !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
if (activeTab === 'complete') result = result.filter(r =>
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
@@ -63,6 +76,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
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[];
@@ -71,12 +85,17 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
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;
const shouldInclude = (vals.includes('Present') && hasValue) || (vals.includes('Missing') && !hasValue);
return shouldInclude;
});
} else {
// For other columns, use regular value matching
result = result.filter(r => vals.includes(String(r.row[col] || '')));
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);
}
}
});
@@ -91,7 +110,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
}
return result;
}, [data, activeTab, search, lineFilter, licenseFilter, sortCol, sortDesc]);
}, [data, activeTab, search, lineFilter, licenseFilter, columnFilters, sortCol, sortDesc]);
const paginatedData = useMemo(() => {
const start = (page - 1) * pageSize;
@@ -109,6 +128,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
}
};
const handleResize = (colIndex: number, e: React.MouseEvent) => {
e.preventDefault();
const startX = e.pageX;
const startWidth = columnWidths[colIndex] || 100;
const onMouseMove = (moveEvent: MouseEvent) => {
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
};
const onMouseUp = () => {
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('mouseup', onMouseUp);
};
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);
};
const getUniqueValues = (col: number) => {
// For description columns, return only 'Present' and 'Missing'
if (DESCRIPTION_COLUMNS.includes(col)) {
@@ -120,12 +158,15 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
};
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];
return { ...prev, [col]: next };
const updated = { ...prev, [col]: next };
console.log('[Filter] new columnFilters:', updated);
return updated;
});
setPage(1);
};
@@ -172,10 +213,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
{ id: 'all', label: 'All Products' },
{ id: 'missingLongDE', label: 'Missing Long DE' },
{ id: 'missingLongEN', label: 'Missing Long EN' },
{ id: 'missingLongAny', label: 'Missing Long DE/EN' },
{ id: 'missingShortDE', label: 'Missing Short DE' },
{ id: 'missingShortEN', label: 'Missing Short EN' },
{ id: 'missingShortAny', label: 'Missing Short DE/EN' },
{ id: 'complete', label: 'Complete' },
{ id: 'incomplete', label: 'Incomplete' },
];
@@ -242,12 +281,13 @@ 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="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">
<tr>
{[
{ col: COLUMNS.ARTICLE_NO, label: 'Article No.' },
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
...(asinColumnIndex !== null ? [{ col: asinColumnIndex, label: 'ASIN' }] : []),
{ col: COLUMNS.LINE, label: 'Line' },
{ col: COLUMNS.LICENSE, label: 'License' },
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
@@ -258,13 +298,14 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
].map(({ col, label }) => (
<th
key={col}
className="px-4 py-3 font-medium transition-colors select-none group relative"
className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
>
<div className="flex items-center justify-between gap-1">
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
<div className="flex items-center justify-between gap-1 overflow-hidden">
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
{label}
{sortCol === col && (
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
sortDesc ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronUp className="w-4 h-4 shrink-0" />
)}
</div>
<button
@@ -273,7 +314,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
setOpenFilterCol(openFilterCol === col ? null : col);
}}
className={cn(
"p-1 rounded hover:bg-slate-700 transition-colors",
"p-1 rounded hover:bg-slate-700 transition-colors shrink-0",
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
)}
>
@@ -281,6 +322,12 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
</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-blue-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
/>
{openFilterCol === col && (
<ColumnFilterPopover
uniqueValues={getUniqueValues(col)}
@@ -300,17 +347,30 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
)}
</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>
</thead>
<tbody className="divide-y divide-slate-700/50">
{paginatedData.map(({ row, index }) => (
<tr key={index} className={cn("transition-colors", getRowColor(row))}>
<td className="px-4 py-3 font-mono text-slate-300 text-xs">{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-4 py-3 font-medium text-white max-w-[200px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
<td className="px-4 py-3 text-slate-300 text-xs">{row[COLUMNS.LINE]}</td>
<td className="px-4 py-3 text-slate-300 text-xs truncate max-w-[120px]" title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
<td className="px-4 py-3">
{paginatedData.map(({ row, index }) => {
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
return (
<tr
key={index}
className={cn(
"transition-colors",
getRowColor(row),
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
)}
>
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
<td className="px-4 py-3 font-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] || 150 }} 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')
@@ -320,10 +380,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
{row[COLUMNS.CLASSIFICATION] || '—'}
</span>
</td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.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">
<button
onClick={() => onEdit(index)}
@@ -334,10 +394,11 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
</button>
</td>
</tr>
))}
);
})}
{paginatedData.length === 0 && (
<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.
</td>
</tr>
+4 -2
View File
@@ -1,10 +1,10 @@
import React from 'react';
import { FileText, Table, Box, DollarSign, Package } from 'lucide-react';
import { FileText, Table, Box, DollarSign, Package, Clock, History } from 'lucide-react';
import { cn } from '../lib/utils';
interface SidebarProps {
activeModule: string;
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing') => void;
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history') => void;
}
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
@@ -14,6 +14,8 @@ export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
{ id: 'article_details', label: 'Article Details', icon: Package },
{ id: 'dimensions', label: 'Dimensions', icon: Box },
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
{ id: 'pending_validation', label: 'Pending Validation', icon: Clock },
{ id: 'history', label: 'Change History', icon: History },
] as const;
return (
+109 -9
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { Download, Database, LogOut, Undo2 } from 'lucide-react';
import React, { useState, useRef, useEffect } from 'react';
import { Download, LogOut, Undo2, CloudUpload, Loader2, ChevronDown, RotateCcw } from 'lucide-react';
import { cn } from '../lib/utils';
interface TopBarProps {
stats: any;
@@ -12,9 +13,33 @@ interface TopBarProps {
onUndo: () => void;
undoMessage?: string;
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 (
<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">
@@ -55,6 +80,77 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
)}
<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 && (
<button
onClick={onExport}
@@ -70,21 +166,25 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
</button>
)}
{canUndo && (
<button
onClick={onUndo}
title={`Undo: ${undoMessage}`}
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"
disabled={!canUndo}
title={canUndo ? `Undo: ${undoMessage}` : 'No changes to undo'}
className={cn(
"flex items-center gap-2 px-4 py-2 rounded-md text-sm font-bold transition-all shadow-lg relative group",
canUndo
? "bg-amber-600 hover:bg-amber-700 text-white shadow-amber-900/40 cursor-pointer"
: "bg-slate-800 text-slate-600 shadow-none cursor-not-allowed opacity-50"
)}
>
<Undo2 className="w-4 h-4" />
BACK / UNDO
{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">
{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 && (
<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_ANON_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
const ALLOWED_DOMAIN = '@craze-group.com';
const SESSION_KEY = 'craze_auth_session';
export interface AuthSession {
@@ -9,10 +8,6 @@ export interface AuthSession {
}
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`, {
method: 'POST',
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> {
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`, {
method: 'POST',
headers: {
+129 -25
View File
@@ -3,26 +3,34 @@ import { ExcelRow } from '../types';
const SUPABASE_URL = 'https://hwithddwaapyhnfwcesj.supabase.co';
const SUPABASE_KEY = 'sb_publishable_fGXkh0bSrAOqSk2jWKAzSg_NJD9YPCv';
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) {
async function fetchWithRetry(
url: string,
options: RequestInit,
retries = 2,
delayMs = 1000
): Promise<Response> {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
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()
})
});
const res = await fetch(url, options);
if (res.ok || res.status < 500 || attempt === retries) return res;
} catch (err) {
lastError = err as Error;
if (attempt === retries) throw lastError;
}
await new Promise(r => setTimeout(r, delayMs));
}
throw lastError ?? new Error('fetch failed');
}
if (response.status === 204 || response.ok) {
// If PATCH didn't find the record, try UPSERT
const upsertResponse = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, {
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise<boolean> {
try {
// Single upsert: POST with Prefer=resolution=merge-duplicates
// This handles both INSERT (new article) and UPDATE (existing) atomically.
// The old PATCH approach silently failed for new articles because Supabase
// returns 200 OK with an empty body when no rows match — indistinguishable
// from a successful update.
const response = await fetchWithRetry(`${SUPABASE_URL}/rest/v1/products_sync`, {
method: 'POST',
headers: {
'apikey': SUPABASE_KEY,
@@ -33,10 +41,15 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) {
body: JSON.stringify({
id: articleNo,
data: rowData,
status_check: 'pending',
updated_at: new Date().toISOString()
})
});
return upsertResponse.ok;
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('Supabase upsert failed:', response.status, errorData);
return false;
}
return true;
} catch (error) {
@@ -45,21 +58,29 @@ export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow) {
}
}
export async function getAllSyncedRows(): Promise<Record<string, ExcelRow>> {
export async function getAllSyncedRows(): Promise<Record<string, { data: ExcelRow, status: string }>> {
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync`, {
// Explicit limit to avoid Supabase's default 1000-row cap
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_sync?select=id,data,status_check&limit=10000`,
{
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Range': '0-9999'
}
});
}
);
if (!response.ok) return {};
const data = await response.json();
const result: Record<string, ExcelRow> = {};
const result: Record<string, { data: ExcelRow, status: string }> = {};
data.forEach((item: any) => {
result[item.id] = item.data;
result[item.id] = {
data: item.data,
status: item.status_check || 'original'
};
});
return result;
} catch (error) {
@@ -67,3 +88,86 @@ export async function getAllSyncedRows(): Promise<Record<string, ExcelRow>> {
return {};
}
}
export async function resetAllPendingRows(): Promise<boolean> {
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?status_check=eq.pending`, {
method: 'PATCH',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
status_check: 'original',
updated_at: new Date().toISOString()
})
});
return response.ok;
} catch (error) {
console.error('Error resetting statuses in Supabase:', error);
return false;
}
}
export interface HistoryEntry {
id: string;
product_id: string;
article_name: string;
old_data: ExcelRow;
new_data: ExcelRow;
changed_at: string;
changed_by: string;
}
export async function saveHistoryEntry(
articleNo: string,
articleName: string,
oldData: ExcelRow,
newData: ExcelRow,
userEmail: string
): Promise<boolean> {
try {
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_history`, {
method: 'POST',
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
product_id: articleNo,
article_name: articleName,
old_data: oldData,
new_data: newData,
changed_at: new Date().toISOString(),
changed_by: userEmail
})
});
return response.ok;
} catch (error) {
console.error('Error saving history to Supabase:', error);
return false;
}
}
export async function getHistory(): Promise<HistoryEntry[]> {
try {
const response = await fetch(
`${SUPABASE_URL}/rest/v1/products_history?select=*&order=changed_at.desc&limit=100`,
{
headers: {
'apikey': SUPABASE_KEY,
'Authorization': `Bearer ${SUPABASE_KEY}`
}
}
);
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error('Error fetching history from Supabase:', error);
return [];
}
}
+4 -2
View File
@@ -6,6 +6,7 @@ export interface AppState {
fileName: string;
fileDate: Date | null;
hasUnsavedChanges: boolean;
asinColumnIndex: number | null;
}
export const COLUMNS = {
@@ -23,11 +24,12 @@ export const COLUMNS = {
SHORT_DE: 64,
SHORT_EN: 65,
RECOMMENDED_AGE: 67,
CLASSIFICATION: 11, // Column L (index 11)
ITEM_AVAILABLE: 14, // Column O (index 14)
CLASSIFICATION: 11,
ITEM_AVAILABLE: 14,
MOQ: 27,
UNITS_INNER: 31,
UNITS_OUTER: 32,
ASIN: 33,
INNER_W: 42,
INNER_L: 43,
INNER_H: 44,