mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 12:55:23 +02:00
Compare commits
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
├── types.ts # TypeScript types and constants
|
||||
├── App.tsx # Main application
|
||||
└── main.tsx # Entry point
|
||||
├── 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');
|
||||
}
|
||||
if (typeof val === 'number' && val >= 25569 && val <= 60000) {
|
||||
const excelEpoch = new Date(1899, 11, 30);
|
||||
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`
|
||||
|
||||
+158
-50
@@ -6,19 +6,22 @@ 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);
|
||||
window.location.href = '/';
|
||||
};
|
||||
|
||||
if (!session) {
|
||||
@@ -32,11 +35,14 @@ export default function App() {
|
||||
fileDate: null,
|
||||
hasUnsavedChanges: false
|
||||
});
|
||||
const [activeModule, setActiveModule] = useState<'descriptions' | '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(() => {
|
||||
const loadDefaultData = async () => {
|
||||
@@ -77,18 +83,24 @@ export default function App() {
|
||||
|
||||
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) => {
|
||||
if (val === undefined || val === null || val === '') return val;
|
||||
const header = (rawHeaders[idx] || '').toLowerCase();
|
||||
|
||||
|
||||
// Skip Article No, Barcodes, and other code-like fields
|
||||
// But allow if it's a weight/measure column (e.g. Article NW (kg))
|
||||
if ((header.includes('id') || header.includes('no') || header.includes('code') ||
|
||||
header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
|
||||
!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
|
||||
if ((header.includes('id') || header.includes('no') || header.includes('code') ||
|
||||
header.includes('art.') || header.includes('barcode') || header.includes('article')) &&
|
||||
!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
|
||||
return val;
|
||||
}
|
||||
|
||||
@@ -98,7 +110,7 @@ export default function App() {
|
||||
if (typeof val === 'number') {
|
||||
return Number(val.toFixed(2));
|
||||
}
|
||||
|
||||
|
||||
if (typeof val === 'string') {
|
||||
const normalized = val.trim().replace(',', '.');
|
||||
const num = parseFloat(normalized);
|
||||
@@ -148,18 +160,18 @@ export default function App() {
|
||||
const wsname = wb.SheetNames[0];
|
||||
const ws = wb.Sheets[wsname];
|
||||
const data = XLSX.utils.sheet_to_json<any[]>(ws, { header: 1 });
|
||||
|
||||
|
||||
if (data.length > 0) {
|
||||
const rawHeaders = data[0];
|
||||
const rawRows = data.slice(1);
|
||||
|
||||
|
||||
const processedRows = rawRows.map(row => {
|
||||
return row.map((val, idx) => {
|
||||
if (val === undefined || val === null || val === '') return val;
|
||||
const header = (rawHeaders[idx] || '').toLowerCase();
|
||||
|
||||
if (header.includes('id') || header.includes('no') || header.includes('code') ||
|
||||
header.includes('art.') || header.includes('barcode') || header.includes('article')) {
|
||||
|
||||
if (header.includes('id') || header.includes('no') || header.includes('code') ||
|
||||
header.includes('art.') || header.includes('barcode') || header.includes('article')) {
|
||||
// But allow if it's a weight/measure column (e.g. Article NW (kg))
|
||||
if (!(header.includes('nw') || header.includes('gw') || header.includes('weight') || header.includes('kg'))) {
|
||||
return val;
|
||||
@@ -182,7 +194,7 @@ export default function App() {
|
||||
if (typeof val === 'number') {
|
||||
return Number(val.toFixed(2));
|
||||
}
|
||||
|
||||
|
||||
if (typeof val === 'string') {
|
||||
const normalized = val.trim().replace(',', '.');
|
||||
const num = parseFloat(normalized);
|
||||
@@ -209,43 +221,82 @@ export default function App() {
|
||||
|
||||
const handleExport = () => {
|
||||
if (appState.data.length === 0) return;
|
||||
|
||||
|
||||
const wsData = [appState.headers, ...appState.data];
|
||||
const ws = XLSX.utils.aoa_to_sheet(wsData);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Products');
|
||||
|
||||
|
||||
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 };
|
||||
});
|
||||
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);
|
||||
};
|
||||
|
||||
// 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.");
|
||||
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) => {
|
||||
@@ -254,15 +305,15 @@ 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;
|
||||
});
|
||||
};
|
||||
|
||||
const handleUndo = () => {
|
||||
if (undoHistory.length === 0) return;
|
||||
|
||||
|
||||
const [lastAction, ...remainingHistory] = undoHistory;
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
@@ -317,6 +368,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} />
|
||||
@@ -345,13 +401,14 @@ export default function App() {
|
||||
) : (
|
||||
<>
|
||||
{activeModule === 'descriptions' && (
|
||||
<ProductDescriptions
|
||||
data={appState.data}
|
||||
onEdit={(index) => setEditingRowIndex(index)}
|
||||
<ProductDescriptions
|
||||
data={appState.data}
|
||||
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' && (
|
||||
@@ -361,6 +418,8 @@ export default function App() {
|
||||
onEdit={(index) => setEditingRowIndex(index)}
|
||||
onSaveRow={handleSaveRow}
|
||||
onCaptureState={captureState}
|
||||
rowStatuses={rowStatuses}
|
||||
onRevertRow={handleRevertRow}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -371,6 +430,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),
|
||||
}
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -378,15 +481,20 @@ export default function App() {
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<UndoToast
|
||||
undoState={undoHistory[0] || null}
|
||||
onUndo={handleUndo}
|
||||
onClose={() => setUndoHistory([])}
|
||||
<UndoToast
|
||||
undoState={undoHistory[0] || null}
|
||||
onUndo={handleUndo}
|
||||
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 && (
|
||||
<EditPanel
|
||||
row={appState.data[editingRowIndex]}
|
||||
<EditPanel
|
||||
row={appState.data[editingRowIndex]}
|
||||
rowIndex={editingRowIndex}
|
||||
onSave={handleSaveRow}
|
||||
onClose={() => setEditingRowIndex(null)}
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, Info, Package, X as XIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface ArticleDetailsProps {
|
||||
data: ExcelRow[];
|
||||
onEdit: (index: number) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
||||
|
||||
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||
[COLUMNS.ARTICLE_NO]: 100,
|
||||
[COLUMNS.ARTICLE_NAME]: 200,
|
||||
[COLUMNS.CLASSIFICATION]: 80,
|
||||
[COLUMNS.ITEM_AVAILABLE]: 80,
|
||||
[COLUMNS.DETAILS_DE]: 150,
|
||||
[COLUMNS.DETAILS_EN]: 150,
|
||||
});
|
||||
|
||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
|
||||
// Tab filter
|
||||
if (activeTab === 'missingDetailsDE') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE]);
|
||||
if (activeTab === 'missingDetailsEN') result = result.filter(r => !r.row[COLUMNS.DETAILS_EN]);
|
||||
if (activeTab === 'missingAnyDetails') result = result.filter(r => !r.row[COLUMNS.DETAILS_DE] || !r.row[COLUMNS.DETAILS_EN]);
|
||||
if (activeTab === 'lowStock') result = result.filter(r => Number(r.row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0);
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
// Dropdown filters
|
||||
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
|
||||
|
||||
// Column-specific filters (Excel-like)
|
||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||
const vals = selectedValues as string[];
|
||||
if (vals.length > 0) {
|
||||
result = result.filter(r => vals.includes(String(r.row[Number(colIdx)] || '')));
|
||||
}
|
||||
});
|
||||
|
||||
// Sorting
|
||||
if (sortCol !== null) {
|
||||
result.sort((a, b) => {
|
||||
const valA = a.row[sortCol];
|
||||
const valB = b.row[sortCol];
|
||||
|
||||
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
return sortDesc ? valB - valA : valA - valB;
|
||||
}
|
||||
|
||||
const sA = String(valA || '');
|
||||
const sB = String(valB || '');
|
||||
return sortDesc ? sB.localeCompare(sA) : sA.localeCompare(sB);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, activeTab, search, lineFilter, columnFilters, sortCol, sortDesc]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, page, pageSize]);
|
||||
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
const handleSort = (col: number) => {
|
||||
if (sortCol === col) {
|
||||
setSortDesc(!sortDesc);
|
||||
} else {
|
||||
setSortCol(col);
|
||||
setSortDesc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.pageX;
|
||||
const startWidth = columnWidths[colIndex] || 100;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
|
||||
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
const values = data.map(r => String(r[col] || ''));
|
||||
return Array.from(new Set(values)).sort();
|
||||
};
|
||||
|
||||
const toggleColumnFilter = (col: number, value: string) => {
|
||||
setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
const next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value];
|
||||
return { ...prev, [col]: next };
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const setBatchColumnFilter = (col: number, values: string[]) => {
|
||||
setColumnFilters(prev => ({ ...prev, [col]: values }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const getBadge = (val: any, type: 'success' | 'warning' | 'error' | 'info' = 'info') => {
|
||||
if (!val) return <span className="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-500/10 text-red-400 border border-red-500/20">Empty</span>;
|
||||
|
||||
const styles = {
|
||||
success: "bg-green-500/10 text-green-400 border-green-500/20",
|
||||
warning: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20",
|
||||
error: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
info: "bg-blue-500/10 text-blue-400 border-blue-500/20"
|
||||
};
|
||||
|
||||
return <span className={cn("inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium border", styles[type])}>{val}</span>;
|
||||
};
|
||||
|
||||
const tabs: { id: TabType; label: string }[] = [
|
||||
{ id: 'all', label: 'All Articles' },
|
||||
{ id: 'missingDetailsDE', label: 'No Details DE' },
|
||||
{ id: 'missingDetailsEN', label: 'No Details EN' },
|
||||
{ id: 'missingAnyDetails', label: 'Missing Details' },
|
||||
{ id: 'lowStock', label: 'Out of Stock' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-wrap gap-2 mb-6">
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
activeTab === tab.id
|
||||
? "bg-indigo-600 text-white shadow-md"
|
||||
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6 bg-slate-800/50 p-4 rounded-xl border border-slate-700/50">
|
||||
<div className="flex-1 min-w-[200px] relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SKU or Name..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-900/50 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
|
||||
/>
|
||||
</div>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
Clear All Column Filters
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
value={lineFilter}
|
||||
onChange={e => { setLineFilter(e.target.value); setPage(1); }}
|
||||
className="bg-slate-900 border border-slate-700 rounded-md px-4 py-2 text-sm text-white focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
<option value="">All Lines</option>
|
||||
{lines.map(l => <option key={l} value={String(l)}>{String(l)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-left text-xs" style={{ tableLayout: 'fixed' }}>
|
||||
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{[
|
||||
{ col: COLUMNS.ARTICLE_NO, label: 'SKU' },
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Name' },
|
||||
{ col: COLUMNS.CLASSIFICATION, label: 'Class' },
|
||||
{ col: COLUMNS.ITEM_AVAILABLE, label: 'Stock' },
|
||||
{ col: COLUMNS.DETAILS_DE, label: 'Details DE' },
|
||||
{ col: COLUMNS.DETAILS_EN, label: 'Details EN' },
|
||||
].map(({ col, label }) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
||||
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||
>
|
||||
<div className="flex items-center 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 shrink-0" /> : <ChevronUp className="w-3 h-3 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
}}
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resizer handle */}
|
||||
<div
|
||||
onMouseDown={(e) => handleResize(col, e)}
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
|
||||
/>
|
||||
|
||||
{openFilterCol === col && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
selectedValues={columnFilters[col] || []}
|
||||
onToggle={(val) => toggleColumnFilter(col, val)}
|
||||
onSelectAll={(vals) => setBatchColumnFilter(col, vals)}
|
||||
onClear={() => {
|
||||
setColumnFilters(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[col];
|
||||
return next;
|
||||
});
|
||||
setOpenFilterCol(null);
|
||||
}}
|
||||
onClose={() => setOpenFilterCol(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-3 font-medium text-right" style={{ width: 80 }}>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/30">
|
||||
{paginatedData.map(({ row, index }) => {
|
||||
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
"hover:bg-slate-700/20 transition-colors",
|
||||
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
|
||||
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||
{row[COLUMNS.ARTICLE_NAME]}
|
||||
</td>
|
||||
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||
<span className={cn(
|
||||
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
||||
)}>
|
||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
|
||||
<span className={cn(
|
||||
"font-mono font-bold",
|
||||
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
|
||||
)}>
|
||||
{row[COLUMNS.ITEM_AVAILABLE] || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
|
||||
{row[COLUMNS.DETAILS_DE] ? (
|
||||
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
|
||||
) : getBadge(null)}
|
||||
</td>
|
||||
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
|
||||
{row[COLUMNS.DETAILS_EN] ? (
|
||||
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
|
||||
) : getBadge(null)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
onClick={() => onEdit(index)}
|
||||
className="p-1.5 text-slate-500 hover:text-indigo-400 hover:bg-indigo-400/10 rounded transition-colors"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{paginatedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
||||
No articles found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-xs text-slate-500">
|
||||
<div>Showing {paginatedData.length} of {filteredData.length} articles</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-slate-300">Page {page} of {totalPages || 1}</span>
|
||||
<button
|
||||
disabled={page === totalPages || totalPages === 0}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Check } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface ColumnFilterPopoverProps {
|
||||
uniqueValues: string[];
|
||||
selectedValues: string[];
|
||||
onToggle: (val: string) => void;
|
||||
onSelectAll: (vals: string[]) => void;
|
||||
onClear: () => void;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColumnFilterPopover({
|
||||
uniqueValues,
|
||||
selectedValues,
|
||||
onToggle,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onClose,
|
||||
title,
|
||||
className
|
||||
}: ColumnFilterPopoverProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredValues = useMemo(() => {
|
||||
return uniqueValues.filter(v =>
|
||||
String(v || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
}, [uniqueValues, search]);
|
||||
|
||||
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filter values..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
|
||||
{filteredValues.map(val => (
|
||||
<div
|
||||
key={val}
|
||||
role="checkbox"
|
||||
aria-checked={selectedValues.includes(val)}
|
||||
tabIndex={0}
|
||||
onClick={() => onToggle(val)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); } }}
|
||||
className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group"
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
|
||||
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
|
||||
)}>
|
||||
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
|
||||
</div>
|
||||
))}
|
||||
{filteredValues.length === 0 && (
|
||||
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-700 mt-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isAllSelected) {
|
||||
onSelectAll([]);
|
||||
} else {
|
||||
onSelectAll(uniqueValues);
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors uppercase tracking-tight"
|
||||
>
|
||||
{isAllSelected ? 'Deselect All' : 'Select All'}
|
||||
</button>
|
||||
<span className="text-slate-600 font-bold">•</span>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="text-[10px] font-bold text-slate-400 hover:text-white transition-colors uppercase tracking-tight"
|
||||
>
|
||||
Clear Current
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-black rounded transition-colors shadow-lg active:scale-95 uppercase"
|
||||
>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export function DataCompleteness({ data, headers }: DataCompletenessProps) {
|
||||
COLUMNS.TARIFF_CODE,
|
||||
COLUMNS.COUNTRY_ORIGIN,
|
||||
COLUMNS.RECOMMENDED_AGE,
|
||||
COLUMNS.DETAILS_DE,
|
||||
COLUMNS.DETAILS_EN,
|
||||
COLUMNS.LONG_DE,
|
||||
COLUMNS.LONG_EN,
|
||||
COLUMNS.SHORT_DE,
|
||||
@@ -70,7 +72,7 @@ export function DataCompleteness({ data, headers }: DataCompletenessProps) {
|
||||
<div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<h2 className="text-lg font-semibold text-white">Data Completeness</h2>
|
||||
<p className="text-sm text-slate-400">Evaluating 10 key fields per product.</p>
|
||||
<p className="text-sm text-slate-400">Evaluating 12 key fields per product.</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto flex-1">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2 } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon, Undo2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface DimensionsViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -10,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 {
|
||||
@@ -31,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);
|
||||
@@ -41,6 +44,17 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
sourceRow: ExcelRow,
|
||||
fieldType: 'outer' | 'units' | 'moq' | 'all'
|
||||
} | null>(null);
|
||||
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
|
||||
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
|
||||
clusterKey: string;
|
||||
targetGroupKey: string;
|
||||
selectedIndices: number[];
|
||||
} | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState<string[]>([]);
|
||||
const [classFilter, setClassFilter] = useState<string[]>([]);
|
||||
const [openFilter, setOpenFilter] = useState<'line' | 'class' | null>(null);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const groupMap = new Map<string, { row: ExcelRow; index: number }[]>();
|
||||
@@ -106,8 +120,38 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
}, [data]);
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
return showOnlyInconsistent ? groups.filter(g => g.isInconsistent) : groups;
|
||||
}, [groups, showOnlyInconsistent]);
|
||||
let result = groups;
|
||||
if (showOnlyInconsistent) {
|
||||
result = result.filter(g =>
|
||||
g.isInconsistent ||
|
||||
g.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending')
|
||||
);
|
||||
}
|
||||
|
||||
if (search || lineFilter.length > 0 || classFilter.length > 0) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(g => {
|
||||
const matchesSearch = !search || g.rows.some(({ row }) =>
|
||||
String(row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
const matchesLine = lineFilter.length === 0 || g.rows.some(({ row }) => lineFilter.includes(String(row[COLUMNS.LINE] || '')));
|
||||
const matchesClass = classFilter.length === 0 || g.rows.some(({ row }) => classFilter.includes(String(row[COLUMNS.CLASSIFICATION] || '')));
|
||||
|
||||
return matchesSearch && matchesLine && matchesClass;
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter, rowStatuses]);
|
||||
|
||||
const uniqueLines = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
|
||||
, [data]);
|
||||
|
||||
const uniqueClasses = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.CLASSIFICATION] || '')))).sort()
|
||||
, [data]);
|
||||
|
||||
const nearDuplicateClusters = useMemo((): NearDuplicateCluster[] => {
|
||||
// Two groups are "similar" if every sorted dimension pair differs by < 1 cm absolute
|
||||
@@ -177,6 +221,39 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
setPendingAction({ group, sourceRow, fieldType: 'all' });
|
||||
};
|
||||
|
||||
const executeNearDupSync = async () => {
|
||||
if (!pendingNearDupSync) return;
|
||||
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||
|
||||
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
|
||||
if (!cluster) return;
|
||||
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
|
||||
if (!targetGroup) return;
|
||||
|
||||
const sourceRow = targetGroup.rows[0].row;
|
||||
const innerL = sourceRow[COLUMNS.INNER_L];
|
||||
const innerW = sourceRow[COLUMNS.INNER_W];
|
||||
const innerH = sourceRow[COLUMNS.INNER_H];
|
||||
|
||||
onCaptureState(`Synced inner dimensions to ${targetGroupKey} cm for ${selectedIndices.length} products`);
|
||||
setPendingNearDupSync(null);
|
||||
|
||||
for (const idx of selectedIndices) {
|
||||
const row = data[idx];
|
||||
const updatedRow = [...row];
|
||||
updatedRow[COLUMNS.INNER_L] = innerL;
|
||||
updatedRow[COLUMNS.INNER_W] = innerW;
|
||||
updatedRow[COLUMNS.INNER_H] = innerH;
|
||||
await onSaveRow(idx, updatedRow);
|
||||
}
|
||||
|
||||
setClusterSelections(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[pendingNearDupSync.clusterKey];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const executeSync = async () => {
|
||||
if (!pendingAction) return;
|
||||
const { group, sourceRow, fieldType } = pendingAction;
|
||||
@@ -222,17 +299,83 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between bg-slate-800/50 p-4 rounded-lg border border-slate-700">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
|
||||
<Boxes className="text-blue-400" />
|
||||
Dimension Consistency Check
|
||||
</h2>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Grouping products by Inner Box dimensions to find Packaging or MOQ discrepancies.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-4 bg-slate-800/50 p-4 rounded-lg border border-slate-700">
|
||||
<div className="relative flex-1 min-w-[250px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SKU or Name in groups..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
||||
lineFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Line {lineFilter.length > 0 && `(${lineFilter.length})`}
|
||||
</button>
|
||||
{openFilter === 'line' && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={uniqueLines}
|
||||
selectedValues={lineFilter}
|
||||
onToggle={val => setLineFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
onSelectAll={setLineFilter}
|
||||
onClear={() => setLineFilter([])}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
title="Filter by Line"
|
||||
className="left-auto right-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'class' ? null : 'class')}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-md border text-sm transition-colors",
|
||||
classFilter.length > 0 ? "bg-blue-600/10 border-blue-500/50 text-blue-400" : "bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Class {classFilter.length > 0 && `(${classFilter.length})`}
|
||||
</button>
|
||||
{openFilter === 'class' && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={uniqueClasses}
|
||||
selectedValues={classFilter}
|
||||
onToggle={val => setClassFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
onSelectAll={setClassFilter}
|
||||
onClear={() => setClassFilter([])}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
title="Filter by Classification"
|
||||
className="left-auto right-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(lineFilter.length > 0 || classFilter.length > 0 || search) && (
|
||||
<button
|
||||
onClick={() => { setSearch(''); setLineFilter([]); setClassFilter([]); }}
|
||||
className="p-2 text-red-400 hover:text-red-300 transition-colors"
|
||||
title="Clear all filters"
|
||||
>
|
||||
<XIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-8 w-px bg-slate-700 mx-2 hidden sm:block" />
|
||||
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<label className="flex items-center gap-2 text-sm text-slate-300 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -240,10 +383,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
onChange={e => setShowOnlyInconsistent(e.target.checked)}
|
||||
className="rounded border-slate-600 bg-slate-700 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
Show only inconsistent groups
|
||||
Show only inconsistent
|
||||
</label>
|
||||
<div className="text-xs text-slate-500 bg-slate-900 px-3 py-1.5 rounded-full border border-slate-700">
|
||||
{groups.filter(g => g.isInconsistent).length} Inconsistencies found
|
||||
<div className="text-[10px] font-bold text-amber-500 bg-amber-500/10 px-2 py-1 rounded border border-amber-500/20 whitespace-nowrap">
|
||||
{groups.filter(g => g.isInconsistent).length} ISSUES
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -257,58 +400,129 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
{nearDuplicateClusters.length} cluster{nearDuplicateClusters.length !== 1 ? 's' : ''} with dimensions differing <1 cm per axis
|
||||
</span>
|
||||
</div>
|
||||
{nearDuplicateClusters.map((cluster, ci) => (
|
||||
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = new Set(expandedNearDuplicates);
|
||||
next.has(ci) ? next.delete(ci) : next.add(ci);
|
||||
setExpandedNearDuplicates(next);
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
|
||||
>
|
||||
{expandedNearDuplicates.has(ci) ? <ChevronDown className="w-4 h-4 text-slate-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-slate-500 shrink-0" />}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{cluster.groups.map((g, gi) => (
|
||||
<span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded">
|
||||
{g.key} cm
|
||||
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs text-violet-400/70">
|
||||
— max diff {cluster.maxDiffPct.toFixed(1)} cm
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
{expandedNearDuplicates.has(ci) && (
|
||||
<div className="border-t border-violet-500/20 px-4 py-3 space-y-2">
|
||||
{cluster.groups.map((g, gi) => (
|
||||
<div key={g.key} className="flex items-start gap-4 text-xs">
|
||||
<span className="font-mono text-violet-300 w-32 shrink-0 pt-0.5">{g.key} cm</span>
|
||||
<div>
|
||||
<span className="text-slate-400">{cluster.volumes[gi].toLocaleString()} cm³</span>
|
||||
<span className="text-slate-600 mx-2">·</span>
|
||||
<span className="text-slate-500">{g.rows.length} product{g.rows.length !== 1 ? 's' : ''}: </span>
|
||||
<span className="text-slate-400">
|
||||
{g.rows.slice(0, 5).map(r => r.row[COLUMNS.ARTICLE_NO]).join(', ')}
|
||||
{g.rows.length > 5 && <span className="text-slate-600"> +{g.rows.length - 5} more</span>}
|
||||
</span>
|
||||
</div>
|
||||
{nearDuplicateClusters.map((cluster, ci) => {
|
||||
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
||||
const clusterKey = cluster.groups[0].key;
|
||||
const selection = clusterSelections[clusterKey] ?? new Set<number>();
|
||||
const targetKey = clusterSyncTargets[clusterKey] ?? cluster.groups[0].key;
|
||||
|
||||
return (
|
||||
<div key={clusterKey} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = new Set(expandedNearDuplicates);
|
||||
next.has(ci) ? next.delete(ci) : next.add(ci);
|
||||
setExpandedNearDuplicates(next);
|
||||
}}
|
||||
className="w-full flex items-center gap-4 p-3 hover:bg-violet-500/10 transition-colors text-left"
|
||||
>
|
||||
{expandedNearDuplicates.has(ci) ? <ChevronDown className="w-4 h-4 text-slate-500 shrink-0" /> : <ChevronRight className="w-4 h-4 text-slate-500 shrink-0" />}
|
||||
<div className="flex items-center gap-3 flex-wrap flex-1">
|
||||
{cluster.groups.map((g, gi) => (
|
||||
<span key={g.key} className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded">
|
||||
{g.key} cm
|
||||
<span className="text-slate-500 ml-1">({cluster.volumes[gi].toLocaleString()} cm³)</span>
|
||||
</span>
|
||||
))}
|
||||
<span className="text-xs text-violet-400/70">— max diff {cluster.maxDiffPct.toFixed(1)} cm</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 shrink-0">{allClusterRows.length} products</span>
|
||||
</button>
|
||||
|
||||
{expandedNearDuplicates.has(ci) && (
|
||||
<div className="border-t border-violet-500/20">
|
||||
<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, [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 => {
|
||||
const repr = g.rows[0].row;
|
||||
return (
|
||||
<option key={g.key} value={g.key}>
|
||||
{repr[COLUMNS.INNER_L]} × {repr[COLUMNS.INNER_W]} × {repr[COLUMNS.INNER_H]} cm
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<button
|
||||
disabled={selection.size === 0}
|
||||
onClick={() => setPendingNearDupSync({ clusterKey, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
|
||||
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Layers className="w-3 h-3" />
|
||||
Sync {selection.size} selected
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set(allClusterRows.map(r => r.index)) }))}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
{selection.size > 0 && (
|
||||
<button
|
||||
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set() }))}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="divide-y divide-violet-500/10">
|
||||
{allClusterRows.map(({ row, index }) => {
|
||||
const isSelected = selection.has(index);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-4 py-2.5 hover:bg-violet-500/5 cursor-pointer transition-colors",
|
||||
isSelected && "bg-violet-500/10"
|
||||
)}
|
||||
onClick={() => setClusterSelections(prev => {
|
||||
const current = new Set(prev[clusterKey] ?? []);
|
||||
if (current.has(index)) current.delete(index); else current.add(index);
|
||||
return { ...prev, [clusterKey]: current };
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
readOnly
|
||||
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs text-slate-300 font-medium shrink-0">{row[COLUMNS.ARTICLE_NO]}</span>
|
||||
<span className="text-xs text-slate-500 truncate">{row[COLUMNS.ARTICLE_NAME]}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="font-mono text-xs text-violet-300 bg-violet-400/10 px-2 py-0.5 rounded shrink-0">
|
||||
{row[COLUMNS.INNER_L]} × {row[COLUMNS.INNER_W]} × {row[COLUMNS.INNER_H]} cm
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredGroups.map(group => (
|
||||
<div key={group.key} className={cn(
|
||||
"border rounded-lg overflow-hidden transition-all",
|
||||
group.isInconsistent ? "border-amber-500/30 bg-amber-500/5" : "border-slate-700 bg-slate-800/30"
|
||||
)}>
|
||||
{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">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
@@ -320,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
|
||||
@@ -379,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>
|
||||
@@ -437,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">
|
||||
@@ -484,6 +720,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
type="warning"
|
||||
confirmText="Sync Group"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!pendingNearDupSync}
|
||||
onConfirm={executeNearDupSync}
|
||||
onCancel={() => setPendingNearDupSync(null)}
|
||||
title="Sync Inner Dimensions"
|
||||
message={`Update inner dimensions to ${pendingNearDupSync?.targetGroupKey} cm for ${pendingNearDupSync?.selectedIndices.length} selected product${(pendingNearDupSync?.selectedIndices.length ?? 0) !== 1 ? 's' : ''}?`}
|
||||
type="warning"
|
||||
confirmText="Sync Dimensions"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { X, Sparkles, Save, Loader2, Languages, Package } from 'lucide-react';
|
||||
import { X, Sparkles, Save, Loader2, Languages, Package, CheckCircle2 } from 'lucide-react';
|
||||
import { generateGemini } from '../services/gemini';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
@@ -19,6 +19,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
longEn: row[COLUMNS.LONG_EN] || '',
|
||||
shortDe: row[COLUMNS.SHORT_DE] || '',
|
||||
shortEn: row[COLUMNS.SHORT_EN] || '',
|
||||
detailsDe: row[COLUMNS.DETAILS_DE] || '',
|
||||
detailsEn: row[COLUMNS.DETAILS_EN] || '',
|
||||
innerW: row[COLUMNS.INNER_W] || '',
|
||||
innerL: row[COLUMNS.INNER_L] || '',
|
||||
innerH: row[COLUMNS.INNER_H] || '',
|
||||
@@ -33,6 +35,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConfirmOpen, setIsConfirmOpen] = useState(false);
|
||||
const [pendingGeminiField, setPendingGeminiField] = useState<keyof typeof formData | null>(null);
|
||||
const [generatedFields, setGeneratedFields] = useState<Set<string>>(new Set());
|
||||
|
||||
const isModified = (field: keyof typeof formData) => {
|
||||
const colMap: Record<string, number> = {
|
||||
@@ -48,8 +51,12 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
outerH: COLUMNS.OUTER_H,
|
||||
unitsOuter: COLUMNS.UNITS_OUTER,
|
||||
moq: COLUMNS.MOQ,
|
||||
detailsDe: COLUMNS.DETAILS_DE,
|
||||
detailsEn: COLUMNS.DETAILS_EN,
|
||||
};
|
||||
return formData[field] !== (row[colMap[field]] || '');
|
||||
const colIndex = (colMap as Record<string, number>)[field as string];
|
||||
if (colIndex === undefined) return false;
|
||||
return formData[field] !== (row[colIndex] || '');
|
||||
};
|
||||
|
||||
const handleGenerate = async (field: keyof typeof formData) => {
|
||||
@@ -65,7 +72,7 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
|
||||
try {
|
||||
let prompt = '';
|
||||
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${row[COLUMNS.DETAILS_EN] || 'N/A'}\nArticle Details (DE): ${row[COLUMNS.DETAILS_DE] || 'N/A'}`;
|
||||
const baseContext = `Article Name: ${row[COLUMNS.ARTICLE_NAME]}\nArticle Details (EN): ${formData.detailsEn || 'N/A'}\nArticle Details (DE): ${formData.detailsDe || 'N/A'}`;
|
||||
|
||||
const systemPrompt = "You are a professional copywriter and expert translator for CRAZE GmbH, a German toy company. You excel at translating product descriptions between German and English, maintaining the commercial and professional tone while ensuring all technical toy details are accurate. Use clear, engaging language.";
|
||||
|
||||
@@ -101,7 +108,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
if (formData.shortEn) {
|
||||
prompt = `Translate exactly this short English product description into professional German for the toy market:\n\n${formData.shortEn}`;
|
||||
} else if (formData.longDe) {
|
||||
prompt = `Create a short version (2-4 sentences max) of the following German product description:\n\n${formData.longDe}`;
|
||||
const targetChars = Math.round(formData.longDe.length * 0.3);
|
||||
prompt = `Create a concise summary of the following German product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional German for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longDe}`;
|
||||
} else {
|
||||
prompt = `Based on the following product details, generate a short commercial description in German (2-4 sentences max).\n\n${baseContext}`;
|
||||
}
|
||||
@@ -109,7 +117,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
if (formData.shortDe) {
|
||||
prompt = `Translate exactly this short German product description into professional English for the toy market:\n\n${formData.shortDe}`;
|
||||
} else if (formData.longEn) {
|
||||
prompt = `Create a short version (2-4 sentences max) of the following English product description:\n\n${formData.longEn}`;
|
||||
const targetChars = Math.round(formData.longEn.length * 0.3);
|
||||
prompt = `Create a concise summary of the following English product description. The summary must be approximately ${targetChars} characters long (about 30% of the original). Preserve the most important commercial highlights and features in natural, professional English for B2B toy buyers. Do not use bullet points — write flowing prose.\n\nOriginal description:\n${formData.longEn}`;
|
||||
} else {
|
||||
prompt = `Based on the following product details, generate a short commercial description in English (2-4 sentences max).\n\n${baseContext}`;
|
||||
}
|
||||
@@ -117,6 +126,12 @@ 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 {
|
||||
@@ -143,6 +158,8 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
newRow[COLUMNS.OUTER_H] = formData.outerH;
|
||||
newRow[COLUMNS.UNITS_OUTER] = formData.unitsOuter;
|
||||
newRow[COLUMNS.MOQ] = formData.moq;
|
||||
newRow[COLUMNS.DETAILS_DE] = formData.detailsDe;
|
||||
newRow[COLUMNS.DETAILS_EN] = formData.detailsEn;
|
||||
onSave(rowIndex, newRow);
|
||||
};
|
||||
|
||||
@@ -162,6 +179,10 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const FieldEditor = ({ title, field }: { title: string, field: keyof typeof formData }) => (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -185,10 +206,18 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
{((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
|
||||
id={`field-${String(field)}`}
|
||||
value={formData[field]}
|
||||
onChange={e => setFormData(prev => ({ ...prev, [field]: e.target.value }))}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
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"
|
||||
@@ -230,11 +259,27 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-xs text-slate-500 mb-1">Details (DE)</span>
|
||||
<p className="text-sm text-slate-300 line-clamp-2" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE] || '-'}</p>
|
||||
<textarea
|
||||
value={formData.detailsDe}
|
||||
onChange={e => setFormData(prev => ({ ...prev, detailsDe: e.target.value }))}
|
||||
className={cn(
|
||||
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
isModified('detailsDe') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
|
||||
)}
|
||||
placeholder="Enter details in German..."
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<span className="block text-xs text-slate-500 mb-1">Details (EN)</span>
|
||||
<p className="text-sm text-slate-300 line-clamp-2" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN] || '-'}</p>
|
||||
<textarea
|
||||
value={formData.detailsEn}
|
||||
onChange={e => setFormData(prev => ({ ...prev, detailsEn: e.target.value }))}
|
||||
className={cn(
|
||||
"w-full h-20 bg-slate-900 border rounded p-2 text-sm text-white focus:outline-none focus:ring-1 transition-colors resize-none",
|
||||
isModified('detailsEn') ? "border-blue-500 focus:ring-blue-500" : "border-slate-700/50"
|
||||
)}
|
||||
placeholder="Enter details in English..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -281,7 +326,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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+162
-11
@@ -1,19 +1,100 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow } from '../types';
|
||||
import { Search, Filter, ChevronDown, ChevronUp, X as XIcon } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
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('');
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [sortCol, setSortCol] = useState<number | null>(null);
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
|
||||
// Global search
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
r.row.some(cell => String(cell || '').toLowerCase().includes(s))
|
||||
);
|
||||
}
|
||||
|
||||
// Column-specific filters
|
||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||
const vals = selectedValues as string[];
|
||||
if (vals.length > 0) {
|
||||
result = result.filter(r => vals.includes(String(r.row[Number(colIdx)] || '')));
|
||||
}
|
||||
});
|
||||
|
||||
// Sorting
|
||||
if (sortCol !== null) {
|
||||
result.sort((a, b) => {
|
||||
const valA = String(a.row[sortCol] || '').toLowerCase();
|
||||
const valB = String(b.row[sortCol] || '').toLowerCase();
|
||||
|
||||
// Handle numbers sorting correctly
|
||||
const numA = parseFloat(valA.replace(',', '.'));
|
||||
const numB = parseFloat(valB.replace(',', '.'));
|
||||
|
||||
if (!isNaN(numA) && !isNaN(numB)) {
|
||||
return sortDesc ? numB - numA : numA - numB;
|
||||
}
|
||||
|
||||
return sortDesc
|
||||
? valB.localeCompare(valA)
|
||||
: valA.localeCompare(valB);
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, search, columnFilters, sortCol, sortDesc]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
return data.slice(start, start + pageSize);
|
||||
}, [data, page, pageSize]);
|
||||
return filteredData.slice(start, start + pageSize);
|
||||
}, [filteredData, page, pageSize]);
|
||||
|
||||
const handleSort = (col: number) => {
|
||||
if (sortCol === col) {
|
||||
setSortDesc(!sortDesc);
|
||||
} else {
|
||||
setSortCol(col);
|
||||
setSortDesc(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
const values = data.map(r => String(r[col] || ''));
|
||||
return Array.from(new Set(values)).sort();
|
||||
};
|
||||
|
||||
const toggleColumnFilter = (col: number, value: string) => {
|
||||
setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
const next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value];
|
||||
return { ...prev, [col]: next };
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const setBatchColumnFilter = (col: number, values: string[]) => {
|
||||
setColumnFilters(prev => ({ ...prev, [col]: values }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const formatCellValue = (val: any, header: string = '') => {
|
||||
if (val === undefined || val === null || val === '') return '';
|
||||
@@ -72,13 +153,39 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||
return val;
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(data.length / pageSize);
|
||||
const totalPages = Math.ceil(filteredData.length / pageSize);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50">
|
||||
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
|
||||
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
|
||||
<div className="p-4 border-b border-slate-700 bg-slate-800/50 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">Matrix View</h2>
|
||||
<p className="text-sm text-slate-400">All data fields formatted to 2 decimal places for numbers/prices.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative min-w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search all columns..."
|
||||
value={search}
|
||||
onChange={e => { setSearch(e.target.value); setPage(1); }}
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-medium"
|
||||
/>
|
||||
</div>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
Clear All Column Filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-auto flex-1">
|
||||
@@ -86,14 +193,58 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||
<thead className="bg-slate-900/50 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{headers.map((header, index) => (
|
||||
<th key={index} className="px-4 py-3 font-medium border-b border-slate-700">
|
||||
{header}
|
||||
<th
|
||||
key={index}
|
||||
className="px-4 py-3 font-medium border-b border-slate-700 transition-colors select-none group relative"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-white"
|
||||
onClick={() => handleSort(index)}
|
||||
>
|
||||
{header}
|
||||
{sortCol === index && (
|
||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === index ? null : index);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
||||
(columnFilters[index]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10 opacity-100" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{openFilterCol === index && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={getUniqueValues(index)}
|
||||
selectedValues={columnFilters[index] || []}
|
||||
onToggle={(val) => toggleColumnFilter(index, val)}
|
||||
onSelectAll={(vals) => setBatchColumnFilter(index, vals)}
|
||||
onClear={() => {
|
||||
setColumnFilters(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
setOpenFilterCol(null);
|
||||
}}
|
||||
onClose={() => setOpenFilterCol(null)}
|
||||
title={header}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{paginatedData.map((row, rowIndex) => (
|
||||
{paginatedData.map(({ row, index: rowIndex }) => (
|
||||
<tr key={rowIndex} className="hover:bg-slate-700/30 transition-colors">
|
||||
{headers.map((header, colIndex) => {
|
||||
const formattedValue = formatCellValue(row[colIndex], header);
|
||||
@@ -122,7 +273,7 @@ export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||
|
||||
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-sm text-slate-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Showing {Math.min((page - 1) * pageSize + 1, data.length)} to {Math.min(page * pageSize, data.length)} of {data.length} entries</span>
|
||||
<span>Showing {Math.min((page - 1) * pageSize + 1, filteredData.length)} to {Math.min(page * pageSize, filteredData.length)} of {filteredData.length} entries</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+212
-41
@@ -10,8 +10,12 @@ import {
|
||||
X,
|
||||
ChevronDown,
|
||||
Edit2,
|
||||
Filter,
|
||||
Check,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface PricingViewProps {
|
||||
data: ExcelRow[];
|
||||
@@ -19,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 {
|
||||
@@ -41,11 +46,16 @@ function findCol(headers: string[], ...keywords: string[]): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) {
|
||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses }: PricingViewProps) {
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||
const [savingCell, setSavingCell] = useState<{ rowIndex: number; colIndex: number } | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [lineMultiFilter, setLineMultiFilter] = useState<string[]>([]);
|
||||
const [classificationFilter, setClassificationFilter] = useState<string[]>([]);
|
||||
const [nameColFilter, setNameColFilter] = useState('');
|
||||
const [openFilter, setOpenFilter] = useState<'name' | 'line' | 'classification' | null>(null);
|
||||
|
||||
// ── Dynamic column detection ──────────────────────────────────────────────
|
||||
const { uvpIdx, srpCols, containerCols } = useMemo(() => {
|
||||
@@ -127,15 +137,49 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
return { total: analyzedRows.length, withPricing, withUnits, withAny, allOk };
|
||||
}, [analyzedRows]);
|
||||
|
||||
// ── Unique values for column filters ─────────────────────────────────────
|
||||
const uniqueLines = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort(),
|
||||
[data]);
|
||||
|
||||
const uniqueClassifications = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.CLASSIFICATION] || '')))).sort(),
|
||||
[data]);
|
||||
|
||||
// ── Filtered rows ─────────────────────────────────────────────────────────
|
||||
const filteredRows = useMemo(() => {
|
||||
let result = analyzedRows;
|
||||
|
||||
// Mode filter
|
||||
switch (filterMode) {
|
||||
case 'all_errors': return analyzedRows.filter(r => r.hasErrors);
|
||||
case 'pricing_errors': return analyzedRows.filter(r => r.pricingErrors.length > 0);
|
||||
case 'units_errors': return analyzedRows.filter(r => r.unitErrors.length > 0);
|
||||
default: return analyzedRows;
|
||||
case 'all_errors': result = analyzedRows.filter(r => r.hasErrors); break;
|
||||
case 'pricing_errors': result = analyzedRows.filter(r => r.pricingErrors.length > 0); break;
|
||||
case 'units_errors': result = analyzedRows.filter(r => r.unitErrors.length > 0); break;
|
||||
}
|
||||
}, [analyzedRows, filterMode]);
|
||||
|
||||
// Global search (SKU + Name)
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
}
|
||||
|
||||
// Column filters
|
||||
if (nameColFilter) {
|
||||
const s = nameColFilter.toLowerCase();
|
||||
result = result.filter(r => String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s));
|
||||
}
|
||||
if (lineMultiFilter.length > 0) {
|
||||
result = result.filter(r => lineMultiFilter.includes(String(r.row[COLUMNS.LINE] || '')));
|
||||
}
|
||||
if (classificationFilter.length > 0) {
|
||||
result = result.filter(r => classificationFilter.includes(String(r.row[COLUMNS.CLASSIFICATION] || '')));
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [analyzedRows, filterMode, search, nameColFilter, lineMultiFilter, classificationFilter]);
|
||||
|
||||
// ── Inline edit helpers ───────────────────────────────────────────────────
|
||||
const startEdit = (rowIndex: number, colIndex: number, currentValue: string) => {
|
||||
@@ -228,6 +272,47 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full gap-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* ── Search bar ── */}
|
||||
<div className="flex-1 max-w-md relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search SKU or Name..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full pl-4 pr-10 py-2 bg-slate-800 border border-slate-700 rounded-lg text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500">
|
||||
<Package className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Filter tabs ── */}
|
||||
<div className="flex items-center gap-1 bg-slate-800/60 rounded-lg p-1 border border-slate-700/50 w-fit">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilterMode(f.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
filterMode === f.id
|
||||
? 'bg-slate-700 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
<span className={cn(
|
||||
'text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[22px] text-center',
|
||||
filterMode === f.id
|
||||
? 'bg-slate-600 text-white'
|
||||
: f.count > 0 ? `${f.color} bg-current/10` : 'text-slate-500'
|
||||
)}>
|
||||
{f.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Column detection warning ── */}
|
||||
{missingCols.length > 0 && (
|
||||
@@ -248,32 +333,6 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
<StatCard label="All OK" value={stats.allOk} icon={<CheckCircle2 className="w-4 h-4" />} color="emerald" />
|
||||
</div>
|
||||
|
||||
{/* ── Filter tabs ── */}
|
||||
<div className="flex items-center gap-1 bg-slate-800/60 rounded-lg p-1 border border-slate-700/50 w-fit">
|
||||
{FILTERS.map(f => (
|
||||
<button
|
||||
key={f.id}
|
||||
onClick={() => setFilterMode(f.id)}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors',
|
||||
filterMode === f.id
|
||||
? 'bg-slate-700 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-700/40'
|
||||
)}
|
||||
>
|
||||
{f.label}
|
||||
<span className={cn(
|
||||
'text-xs font-bold px-1.5 py-0.5 rounded-full min-w-[22px] text-center',
|
||||
filterMode === f.id
|
||||
? 'bg-slate-600 text-white'
|
||||
: f.count > 0 ? `${f.color} bg-current/10` : 'text-slate-500'
|
||||
)}>
|
||||
{f.count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ── Table ── */}
|
||||
<div className="flex-1 overflow-auto bg-slate-800 rounded-xl border border-slate-700 shadow-xl">
|
||||
{filteredRows.length === 0 ? (
|
||||
@@ -289,11 +348,77 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap">
|
||||
Art. No.
|
||||
</th>
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700">
|
||||
Article Name
|
||||
{/* Article Name with text filter */}
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 group relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Article Name</span>
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'name' ? null : 'name')}
|
||||
className={cn(
|
||||
'p-0.5 rounded hover:bg-slate-700 transition-colors',
|
||||
nameColFilter ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{openFilter === 'name' && (
|
||||
<TextFilterPopover
|
||||
value={nameColFilter}
|
||||
onChange={setNameColFilter}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap">
|
||||
Line
|
||||
{/* Line with multi-select filter */}
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap group relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Line</span>
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'line' ? null : 'line')}
|
||||
className={cn(
|
||||
'p-0.5 rounded hover:bg-slate-700 transition-colors',
|
||||
lineMultiFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{openFilter === 'line' && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={uniqueLines}
|
||||
selectedValues={lineMultiFilter}
|
||||
onToggle={val => setLineMultiFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
onSelectAll={vals => setLineMultiFilter(vals)}
|
||||
onClear={() => { setLineMultiFilter([]); setOpenFilter(null); }}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
{/* Classification with multi-select filter */}
|
||||
<th className="text-left px-3 py-3 text-xs font-semibold text-slate-400 uppercase tracking-wider border-b border-slate-700 whitespace-nowrap group relative">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Classification</span>
|
||||
<button
|
||||
onClick={() => setOpenFilter(openFilter === 'classification' ? null : 'classification')}
|
||||
className={cn(
|
||||
'p-0.5 rounded hover:bg-slate-700 transition-colors',
|
||||
classificationFilter.length > 0 ? 'text-blue-400 bg-blue-400/10' : 'text-slate-500 opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{openFilter === 'classification' && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={uniqueClassifications}
|
||||
selectedValues={classificationFilter}
|
||||
onToggle={val => setClassificationFilter(prev => prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val])}
|
||||
onSelectAll={vals => setClassificationFilter(vals)}
|
||||
onClear={() => { setClassificationFilter([]); setOpenFilter(null); }}
|
||||
onClose={() => setOpenFilter(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
|
||||
{/* Editable pricing columns */}
|
||||
@@ -330,16 +455,21 @@ 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
|
||||
? 'bg-red-950/20'
|
||||
: pricingErrors.length > 0
|
||||
? 'bg-amber-950/10'
|
||||
: ''
|
||||
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'
|
||||
: ''
|
||||
)}
|
||||
>
|
||||
{/* Article No */}
|
||||
@@ -359,6 +489,20 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
{row[COLUMNS.LINE] || '—'}
|
||||
</td>
|
||||
|
||||
{/* Classification */}
|
||||
<td className="px-3 py-2.5">
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 rounded text-[10px] font-bold border whitespace-nowrap',
|
||||
String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().includes('CORE')
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().includes('OOC')
|
||||
? 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
: 'bg-slate-700/50 text-slate-400 border-slate-600/50'
|
||||
)}>
|
||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
{/* Editable pricing cells */}
|
||||
{pricingEditableCols.map(col => {
|
||||
const isEditing = editingCell?.rowIndex === dataIndex && editingCell?.colIndex === col.index;
|
||||
@@ -484,6 +628,33 @@ export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }
|
||||
);
|
||||
}
|
||||
|
||||
// ── Text filter popover ───────────────────────────────────────────────────────
|
||||
function TextFilterPopover({ value, onChange, onClose }: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute top-full left-0 mt-1 w-56 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search article name…"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
autoFocus
|
||||
className="w-full bg-slate-900 border border-slate-700 rounded p-1.5 pl-8 text-xs text-white focus:outline-none focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between pt-1 border-t border-slate-700">
|
||||
<button onClick={() => { onChange(''); onClose(); }} className="text-[10px] font-medium text-slate-400 hover:text-white transition-colors">Clear</button>
|
||||
<button onClick={onClose} className="px-3 py-1 bg-blue-600 hover:bg-blue-700 text-white text-[10px] font-bold rounded transition-colors">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Stat card ─────────────────────────────────────────────────────────────────
|
||||
function StatCard({
|
||||
label,
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
|
||||
interface ProductDescriptionsProps {
|
||||
data: ExcelRow[];
|
||||
onEdit: (index: number) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'missingDeLong' | 'missingEnLong' | 'missingDeShort' | 'missingEnShort' | 'complete' | 'incomplete';
|
||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
|
||||
|
||||
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
|
||||
// Description columns that should only have Present/Missing filters
|
||||
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
|
||||
|
||||
export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescriptionsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
@@ -19,6 +24,19 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
const [sortDesc, setSortDesc] = useState(false);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
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.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]);
|
||||
@@ -27,17 +45,24 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
let result = data.map((row, index) => ({ row, index }));
|
||||
|
||||
// Tab filter
|
||||
if (activeTab === 'missingDeLong') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
||||
if (activeTab === 'missingEnLong') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
||||
if (activeTab === 'missingDeShort') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
||||
if (activeTab === 'missingEnShort') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
||||
if (activeTab === 'complete') result = result.filter(r => r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] && r.row[COLUMNS.SHORT_DE] && r.row[COLUMNS.SHORT_EN]);
|
||||
if (activeTab === 'incomplete') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN] || !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
|
||||
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
||||
if (activeTab === 'missingLongEN') result = result.filter(r => !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 === 'complete') result = result.filter(r =>
|
||||
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
|
||||
r.row[COLUMNS.SHORT_DE] && r.row[COLUMNS.SHORT_EN]
|
||||
);
|
||||
if (activeTab === 'incomplete') result = result.filter(r =>
|
||||
!r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN] ||
|
||||
!r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]
|
||||
);
|
||||
|
||||
// Search filter
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
result = result.filter(r =>
|
||||
String(r.row[COLUMNS.ARTICLE_NO] || '').toLowerCase().includes(s) ||
|
||||
String(r.row[COLUMNS.ARTICLE_NAME] || '').toLowerCase().includes(s)
|
||||
);
|
||||
@@ -47,6 +72,31 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
if (lineFilter) result = result.filter(r => r.row[COLUMNS.LINE] === lineFilter);
|
||||
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
|
||||
|
||||
// Column-specific filters (Excel-like)
|
||||
console.log('[Filter] applying columnFilters:', columnFilters, 'result count before:', result.length);
|
||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||
const col = Number(colIdx);
|
||||
const vals = selectedValues as string[];
|
||||
if (vals.length > 0) {
|
||||
// For description columns, filter by present/missing
|
||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||
result = result.filter(r => {
|
||||
const hasValue = Boolean(r.row[col]);
|
||||
const shouldInclude = (vals.includes('Present') && hasValue) || (vals.includes('Missing') && !hasValue);
|
||||
return shouldInclude;
|
||||
});
|
||||
} else {
|
||||
// For other columns, use regular value matching
|
||||
const before = result.length;
|
||||
result = result.filter(r => {
|
||||
const cellVal = String(r.row[col] ?? '').trim();
|
||||
return vals.some(v => v.trim() === cellVal);
|
||||
});
|
||||
console.log('[Filter] col', col, 'vals', vals, 'before:', before, 'after:', result.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Sorting
|
||||
if (sortCol !== null) {
|
||||
result.sort((a, b) => {
|
||||
@@ -57,7 +107,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;
|
||||
@@ -75,6 +125,54 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.pageX;
|
||||
const startWidth = columnWidths[colIndex] || 100;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
|
||||
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
// For description columns, return only 'Present' and 'Missing'
|
||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||
return ['Present', 'Missing'];
|
||||
}
|
||||
// For other columns, return actual unique values
|
||||
const values = data.map(r => String(r[col] || ''));
|
||||
return Array.from(new Set(values)).sort();
|
||||
};
|
||||
|
||||
const toggleColumnFilter = (col: number, value: string) => {
|
||||
console.log('[Filter] toggleColumnFilter called, col:', col, 'value:', JSON.stringify(value));
|
||||
setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
const next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value];
|
||||
const updated = { ...prev, [col]: next };
|
||||
console.log('[Filter] new columnFilters:', updated);
|
||||
return updated;
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const setBatchColumnFilter = (col: number, values: string[]) => {
|
||||
setColumnFilters(prev => ({ ...prev, [col]: values }));
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const getRowColor = (row: ExcelRow) => {
|
||||
// EOL Rule: OOC Classification and 0 or negative stock (Item Available)
|
||||
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
||||
@@ -84,7 +182,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
return 'bg-yellow-500/10 hover:bg-yellow-500/20'; // EOL Highlight
|
||||
}
|
||||
|
||||
const fields = [row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN], row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]];
|
||||
const fields = [
|
||||
row[COLUMNS.LONG_DE], row[COLUMNS.LONG_EN],
|
||||
row[COLUMNS.SHORT_DE], row[COLUMNS.SHORT_EN]
|
||||
];
|
||||
const filled = fields.filter(Boolean).length;
|
||||
if (filled === 4) return 'bg-green-900/10 hover:bg-green-900/20';
|
||||
if (filled === 0) return 'bg-red-900/10 hover:bg-red-900/20';
|
||||
@@ -93,13 +194,13 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
|
||||
const Badge = ({ content, row }: { content: any, row: ExcelRow }) => {
|
||||
if (content) return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-500/20 text-green-400 border border-green-500/30">✓</span>;
|
||||
|
||||
|
||||
// EOL Exception: OOC and stock <= 0
|
||||
const classification = String(row[COLUMNS.CLASSIFICATION] || '').toUpperCase().trim();
|
||||
const stock = Number(row[COLUMNS.ITEM_AVAILABLE] || 0);
|
||||
|
||||
|
||||
if (classification === 'OOC' && stock <= 0) {
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">EOL not neccessary</span>;
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-500/20 text-yellow-400 border border-yellow-500/30">EOL not neccessary</span>;
|
||||
}
|
||||
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-500/20 text-red-400 border border-red-500/30">✗ Missing</span>;
|
||||
@@ -107,10 +208,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
|
||||
const tabs: { id: TabType; label: string }[] = [
|
||||
{ id: 'all', label: 'All Products' },
|
||||
{ id: 'missingDeLong', label: 'Missing DE Long' },
|
||||
{ id: 'missingEnLong', label: 'Missing EN Long' },
|
||||
{ id: 'missingDeShort', label: 'Missing DE Short' },
|
||||
{ id: 'missingEnShort', label: 'Missing EN Short' },
|
||||
{ id: 'missingLongDE', label: 'Missing Long DE' },
|
||||
{ id: 'missingLongEN', label: 'Missing Long EN' },
|
||||
{ id: 'missingShortDE', label: 'Missing Short DE' },
|
||||
{ id: 'missingShortEN', label: 'Missing Short EN' },
|
||||
{ id: 'complete', label: 'Complete' },
|
||||
{ id: 'incomplete', label: 'Incomplete' },
|
||||
];
|
||||
@@ -124,8 +225,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
onClick={() => { setActiveTab(tab.id); setPage(1); }}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded-md text-sm font-medium transition-colors",
|
||||
activeTab === tab.id
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
activeTab === tab.id
|
||||
? "bg-blue-600 text-white shadow-md"
|
||||
: "bg-slate-800 text-slate-400 hover:bg-slate-700 hover:text-white"
|
||||
)}
|
||||
>
|
||||
@@ -145,6 +246,18 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
className="w-full pl-9 pr-4 py-2 bg-slate-900 border border-slate-700 rounded-md text-sm text-white focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
{(Object.values(columnFilters) as string[][]).some(v => v.length > 0) && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setColumnFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600/10 text-red-400 hover:bg-red-600 hover:text-white rounded-md text-sm font-medium transition-colors border border-red-600/20"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
Clear All Column Filters
|
||||
</button>
|
||||
)}
|
||||
<select
|
||||
value={lineFilter}
|
||||
onChange={e => { setLineFilter(e.target.value); setPage(1); }}
|
||||
@@ -165,7 +278,7 @@ 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>
|
||||
{[
|
||||
@@ -173,38 +286,97 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
{ col: COLUMNS.ARTICLE_NAME, label: 'Article Name' },
|
||||
{ col: COLUMNS.LINE, label: 'Line' },
|
||||
{ col: COLUMNS.LICENSE, label: 'License' },
|
||||
{ col: COLUMNS.CLASSIFICATION, label: 'Classification' },
|
||||
{ col: COLUMNS.LONG_DE, label: 'Long DE' },
|
||||
{ col: COLUMNS.LONG_EN, label: 'Long EN' },
|
||||
{ col: COLUMNS.SHORT_DE, label: 'Short DE' },
|
||||
{ col: COLUMNS.SHORT_EN, label: 'Short EN' },
|
||||
].map(({ col, label }) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-3 font-medium cursor-pointer hover:text-white transition-colors select-none"
|
||||
onClick={() => handleSort(col)}
|
||||
<th
|
||||
key={col}
|
||||
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 gap-1">
|
||||
{label}
|
||||
{sortCol === col && (
|
||||
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
|
||||
)}
|
||||
<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 shrink-0" /> : <ChevronUp className="w-4 h-4 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
}}
|
||||
className={cn(
|
||||
"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"
|
||||
)}
|
||||
>
|
||||
<Filter className="w-3.5 h-3.5" />
|
||||
</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)}
|
||||
selectedValues={columnFilters[col] || []}
|
||||
onToggle={(val) => toggleColumnFilter(col, val)}
|
||||
onSelectAll={(vals) => setBatchColumnFilter(col, vals)}
|
||||
onClear={() => {
|
||||
setColumnFilters(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[col];
|
||||
return next;
|
||||
});
|
||||
setOpenFilterCol(null);
|
||||
}}
|
||||
onClose={() => setOpenFilterCol(null)}
|
||||
/>
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-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">{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-4 py-3 font-medium text-white max-w-[300px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||
<td className="px-4 py-3 text-slate-300">{row[COLUMNS.LINE]}</td>
|
||||
<td className="px-4 py-3 text-slate-300">{row[COLUMNS.LICENSE]}</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>
|
||||
{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>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||
<td className="px-4 py-3 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||
<span className={cn(
|
||||
"px-2 py-0.5 rounded text-[10px] font-bold border",
|
||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
||||
? "bg-amber-500/10 text-amber-500 border-amber-500/20"
|
||||
: "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
||||
)}>
|
||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_DE] }}><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
|
||||
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_EN] }}><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
|
||||
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_DE] }}><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
||||
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_EN] }}><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => onEdit(index)}
|
||||
@@ -215,7 +387,8 @@ 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">
|
||||
@@ -226,7 +399,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="bg-slate-900 border-t border-slate-700 p-4 flex items-center justify-between text-sm text-slate-400">
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Showing {Math.min((page - 1) * pageSize + 1, filteredData.length)} to {Math.min(page * pageSize, filteredData.length)} of {filteredData.length} entries</span>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
import React from 'react';
|
||||
import { FileText, Table, Box, DollarSign } 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' | 'completeness' | 'matrix' | 'dimensions' | 'pricing') => void;
|
||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history') => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
||||
const navItems = [
|
||||
{ id: 'matrix', label: 'Matrix', icon: Table },
|
||||
{ id: 'descriptions', label: 'Product Descriptions', icon: FileText },
|
||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||
{ id: 'pending_validation', label: 'Pending Validation', icon: Clock },
|
||||
{ id: 'history', label: 'Change History', icon: History },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
|
||||
+122
-22
@@ -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,15 +13,39 @@ 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">
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Craze Scan"
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Craze Scan"
|
||||
className="h-24 w-auto object-contain filter brightness-110 contrast-125 hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
@@ -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}
|
||||
@@ -69,22 +165,26 @@ export function TopBar({ stats, onExport, hasData, hasUnsavedChanges, userEmail,
|
||||
{hasUnsavedChanges && <span className="w-2 h-2 rounded-full bg-red-500 ml-1 animate-pulse" />}
|
||||
</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"
|
||||
>
|
||||
<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">
|
||||
{undoSteps}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onUndo}
|
||||
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
|
||||
{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">
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+132
-28
@@ -3,10 +3,35 @@ 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 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');
|
||||
}
|
||||
|
||||
export async function saveRowToSupabase(articleNo: string, rowData: ExcelRow): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(`${SUPABASE_URL}/rest/v1/products_sync?id=eq.${encodeURIComponent(articleNo)}`, {
|
||||
method: 'PATCH',
|
||||
// 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,
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`,
|
||||
@@ -16,27 +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()
|
||||
})
|
||||
});
|
||||
|
||||
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`, {
|
||||
method: 'POST',
|
||||
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()
|
||||
})
|
||||
});
|
||||
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`, {
|
||||
headers: {
|
||||
'apikey': SUPABASE_KEY,
|
||||
'Authorization': `Bearer ${SUPABASE_KEY}`
|
||||
// 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}`,
|
||||
'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 [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user