mirror of
https://github.com/christianvidalwolf-prog/Craze-Data-check.git
synced 2026-08-03 13:05:25 +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`
|
||||
|
||||
+151
-50
@@ -6,20 +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) {
|
||||
@@ -33,11 +35,14 @@ export default function App() {
|
||||
fileDate: null,
|
||||
hasUnsavedChanges: false
|
||||
});
|
||||
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing'>('descriptions');
|
||||
const [activeModule, setActiveModule] = useState<'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history'>('descriptions');
|
||||
const [undoHistory, setUndoHistory] = useState<{ data: ExcelRow[], message: string }[]>([]);
|
||||
const [editingRowIndex, setEditingRowIndex] = useState<number | null>(null);
|
||||
const [isLoadingDefault, setIsLoadingDefault] = useState(true);
|
||||
const [defaultLoadError, setDefaultLoadError] = useState<string | null>(null);
|
||||
const [rowStatuses, setRowStatuses] = useState<Record<string, string>>({});
|
||||
const [pendingRows, setPendingRows] = useState<Record<string, { rowIndex: number; originalData: ExcelRow; newData: ExcelRow; articleName: string }>>({});
|
||||
const [isSavingAll, setIsSavingAll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadDefaultData = async () => {
|
||||
@@ -78,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;
|
||||
}
|
||||
|
||||
@@ -99,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);
|
||||
@@ -149,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;
|
||||
@@ -183,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);
|
||||
@@ -210,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) => {
|
||||
@@ -255,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,
|
||||
@@ -318,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} />
|
||||
@@ -346,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' && (
|
||||
@@ -362,6 +418,8 @@ export default function App() {
|
||||
onEdit={(index) => setEditingRowIndex(index)}
|
||||
onSaveRow={handleSaveRow}
|
||||
onCaptureState={captureState}
|
||||
rowStatuses={rowStatuses}
|
||||
onRevertRow={handleRevertRow}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -372,12 +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),
|
||||
}
|
||||
}));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -385,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)}
|
||||
|
||||
@@ -7,11 +7,12 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
interface ArticleDetailsProps {
|
||||
data: ExcelRow[];
|
||||
onEdit: (index: number) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'missingDetailsDE' | 'missingDetailsEN' | 'missingAnyDetails' | 'lowStock';
|
||||
|
||||
export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
export function ArticleDetails({ data, onEdit, rowStatuses }: ArticleDetailsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
@@ -21,6 +22,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||
[COLUMNS.ARTICLE_NO]: 100,
|
||||
[COLUMNS.ARTICLE_NAME]: 200,
|
||||
[COLUMNS.CLASSIFICATION]: 80,
|
||||
[COLUMNS.ITEM_AVAILABLE]: 80,
|
||||
[COLUMNS.DETAILS_DE]: 150,
|
||||
[COLUMNS.DETAILS_EN]: 150,
|
||||
});
|
||||
|
||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||
|
||||
@@ -70,7 +79,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data, activeTab, search, lineFilter, sortCol, sortDesc]);
|
||||
}, [data, activeTab, search, lineFilter, columnFilters, sortCol, sortDesc]);
|
||||
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (page - 1) * pageSize;
|
||||
@@ -88,6 +97,25 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.pageX;
|
||||
const startWidth = columnWidths[colIndex] || 100;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
|
||||
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
const values = data.map(r => String(r[col] || ''));
|
||||
return Array.from(new Set(values)).sort();
|
||||
@@ -184,7 +212,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
|
||||
<div className="flex-1 bg-slate-800 rounded-xl border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
||||
<div className="overflow-x-auto flex-1">
|
||||
<table className="w-full text-left text-xs">
|
||||
<table className="w-full text-left text-xs" style={{ tableLayout: 'fixed' }}>
|
||||
<thead className="bg-slate-900/80 text-slate-400 sticky top-0 z-10">
|
||||
<tr>
|
||||
{[
|
||||
@@ -197,13 +225,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
].map(({ col, label }) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-3 py-3 font-medium transition-colors select-none group relative"
|
||||
className="px-3 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
||||
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
|
||||
<div className="flex items-center justify-between gap-1 overflow-hidden">
|
||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||
{label}
|
||||
{sortCol === col && (
|
||||
sortDesc ? <ChevronDown className="w-3 h-3" /> : <ChevronUp className="w-3 h-3" />
|
||||
sortDesc ? <ChevronDown className="w-3 h-3 shrink-0" /> : <ChevronUp className="w-3 h-3 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -212,7 +241,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
||||
"p-1 rounded hover:bg-slate-700 transition-colors shrink-0",
|
||||
(columnFilters[col]?.length || 0) > 0 ? "text-indigo-400 bg-indigo-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
@@ -220,6 +249,12 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resizer handle */}
|
||||
<div
|
||||
onMouseDown={(e) => handleResize(col, e)}
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-indigo-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
|
||||
/>
|
||||
|
||||
{openFilterCol === col && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
@@ -239,17 +274,26 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-3 font-medium text-right">Edit</th>
|
||||
<th className="px-3 py-3 font-medium text-right" style={{ width: 80 }}>Edit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/30">
|
||||
{paginatedData.map(({ row, index }) => (
|
||||
<tr key={index} className="hover:bg-slate-700/20 transition-colors">
|
||||
<td className="px-3 py-2 font-mono text-indigo-400">{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-200 max-w-[150px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>
|
||||
{paginatedData.map(({ row, index }) => {
|
||||
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
"hover:bg-slate-700/20 transition-colors",
|
||||
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
|
||||
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-indigo-400 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-3 py-2 font-medium text-slate-200 truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>
|
||||
{row[COLUMNS.ARTICLE_NAME]}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||
<span className={cn(
|
||||
"px-1.5 py-0.5 rounded-[4px] text-[10px] font-bold border",
|
||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC') ? "bg-amber-500/10 text-amber-500 border-amber-500/20" : "bg-slate-700/50 text-slate-400 border-slate-600/50"
|
||||
@@ -257,7 +301,7 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.ITEM_AVAILABLE] }}>
|
||||
<span className={cn(
|
||||
"font-mono font-bold",
|
||||
Number(row[COLUMNS.ITEM_AVAILABLE] || 0) <= 0 ? "text-red-400" : "text-emerald-400"
|
||||
@@ -265,14 +309,14 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
{row[COLUMNS.ITEM_AVAILABLE] || 0}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_DE] }}>
|
||||
{row[COLUMNS.DETAILS_DE] ? (
|
||||
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
|
||||
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_DE]}>{row[COLUMNS.DETAILS_DE]}</div>
|
||||
) : getBadge(null)}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 truncate" style={{ width: columnWidths[COLUMNS.DETAILS_EN] }}>
|
||||
{row[COLUMNS.DETAILS_EN] ? (
|
||||
<div className="max-w-[120px] truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
|
||||
<div className="truncate text-slate-400" title={row[COLUMNS.DETAILS_EN]}>{row[COLUMNS.DETAILS_EN]}</div>
|
||||
) : getBadge(null)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
@@ -284,7 +328,8 @@ export function ArticleDetails({ data, onEdit }: ArticleDetailsProps) {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{paginatedData.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Search, Check, X } from 'lucide-react';
|
||||
import { Search, Check } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface ColumnFilterPopoverProps {
|
||||
@@ -34,10 +34,13 @@ export function ColumnFilterPopover({
|
||||
const isAllSelected = selectedValues.length === uniqueValues.length && uniqueValues.length > 0;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"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
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 mt-1 w-64 bg-slate-800 border border-slate-700 rounded-lg shadow-2xl z-50 p-3 flex flex-col gap-3 animate-in fade-in zoom-in-95 duration-100",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{title && <div className="text-[10px] font-bold text-slate-500 uppercase tracking-wider px-1">{title}</div>}
|
||||
|
||||
<div className="relative">
|
||||
@@ -54,21 +57,23 @@ export function ColumnFilterPopover({
|
||||
|
||||
<div className="max-h-48 overflow-y-auto space-y-0.5 pr-1 custom-scrollbar">
|
||||
{filteredValues.map(val => (
|
||||
<label key={val} className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group">
|
||||
<div
|
||||
key={val}
|
||||
role="checkbox"
|
||||
aria-checked={selectedValues.includes(val)}
|
||||
tabIndex={0}
|
||||
onClick={() => onToggle(val)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onToggle(val); } }}
|
||||
className="flex items-center gap-2 p-1.5 hover:bg-slate-700/50 rounded cursor-pointer group"
|
||||
>
|
||||
<div className={cn(
|
||||
"w-4 h-4 rounded border flex items-center justify-center shrink-0 transition-colors",
|
||||
selectedValues.includes(val) ? "bg-blue-600 border-blue-600" : "border-slate-600 bg-slate-900 group-hover:border-slate-500"
|
||||
)}>
|
||||
{selectedValues.includes(val) && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="hidden"
|
||||
checked={selectedValues.includes(val)}
|
||||
onChange={() => onToggle(val)}
|
||||
/>
|
||||
<span className="text-xs text-slate-300 truncate" title={val}>{val || '(Empty)'}</span>
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
{filteredValues.length === 0 && (
|
||||
<div className="text-[10px] text-slate-500 text-center py-4 italic">No values found</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon } from 'lucide-react';
|
||||
import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Edit2, Package, Boxes, Scale, Loader2, RefreshCw, Layers, Link2, Search, Filter, X as XIcon, Undo2 } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ConfirmModal } from './ConfirmModal';
|
||||
import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
@@ -11,6 +11,8 @@ interface DimensionsViewProps {
|
||||
onEdit: (index: number) => void;
|
||||
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
||||
onCaptureState: (message: string) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
onRevertRow: (articleNo: string) => void;
|
||||
}
|
||||
|
||||
interface DimensionGroup {
|
||||
@@ -32,7 +34,7 @@ interface NearDuplicateCluster {
|
||||
maxDiffPct: number;
|
||||
}
|
||||
|
||||
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState }: DimensionsViewProps) {
|
||||
export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureState, rowStatuses, onRevertRow }: DimensionsViewProps) {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
||||
const [expandedNearDuplicates, setExpandedNearDuplicates] = useState<Set<number>>(new Set());
|
||||
const [showOnlyInconsistent, setShowOnlyInconsistent] = useState(true);
|
||||
@@ -45,7 +47,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
const [clusterSelections, setClusterSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [clusterSyncTargets, setClusterSyncTargets] = useState<Record<number, string>>({});
|
||||
const [pendingNearDupSync, setPendingNearDupSync] = useState<{
|
||||
clusterIndex: number;
|
||||
clusterKey: string;
|
||||
targetGroupKey: string;
|
||||
selectedIndices: number[];
|
||||
} | null>(null);
|
||||
@@ -119,7 +121,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
|
||||
const filteredGroups = useMemo(() => {
|
||||
let result = groups;
|
||||
if (showOnlyInconsistent) result = result.filter(g => g.isInconsistent);
|
||||
if (showOnlyInconsistent) {
|
||||
result = result.filter(g =>
|
||||
g.isInconsistent ||
|
||||
g.rows.some(({ row }) => rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending')
|
||||
);
|
||||
}
|
||||
|
||||
if (search || lineFilter.length > 0 || classFilter.length > 0) {
|
||||
const s = search.toLowerCase();
|
||||
@@ -136,7 +143,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter]);
|
||||
}, [groups, showOnlyInconsistent, search, lineFilter, classFilter, rowStatuses]);
|
||||
|
||||
const uniqueLines = useMemo(() =>
|
||||
Array.from(new Set(data.map(r => String(r[COLUMNS.LINE] || '')))).sort()
|
||||
@@ -216,9 +223,10 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
|
||||
const executeNearDupSync = async () => {
|
||||
if (!pendingNearDupSync) return;
|
||||
const { clusterIndex, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||
const { clusterKey, targetGroupKey, selectedIndices } = pendingNearDupSync;
|
||||
|
||||
const cluster = nearDuplicateClusters[clusterIndex];
|
||||
const cluster = nearDuplicateClusters.find(c => c.groups[0].key === clusterKey);
|
||||
if (!cluster) return;
|
||||
const targetGroup = cluster.groups.find(g => g.key === targetGroupKey);
|
||||
if (!targetGroup) return;
|
||||
|
||||
@@ -241,7 +249,7 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
|
||||
setClusterSelections(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[clusterIndex];
|
||||
delete next[pendingNearDupSync.clusterKey];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
@@ -394,11 +402,12 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
</div>
|
||||
{nearDuplicateClusters.map((cluster, ci) => {
|
||||
const allClusterRows = cluster.groups.flatMap(g => g.rows);
|
||||
const selection = clusterSelections[ci] ?? new Set<number>();
|
||||
const targetKey = clusterSyncTargets[ci] ?? cluster.groups[0].key;
|
||||
const clusterKey = cluster.groups[0].key;
|
||||
const selection = clusterSelections[clusterKey] ?? new Set<number>();
|
||||
const targetKey = clusterSyncTargets[clusterKey] ?? cluster.groups[0].key;
|
||||
|
||||
return (
|
||||
<div key={ci} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||
<div key={clusterKey} className="border border-violet-500/25 bg-violet-500/5 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => {
|
||||
const next = new Set(expandedNearDuplicates);
|
||||
@@ -422,12 +431,11 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
|
||||
{expandedNearDuplicates.has(ci) && (
|
||||
<div className="border-t border-violet-500/20">
|
||||
{/* Sync toolbar */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 bg-violet-500/5 border-b border-violet-500/10 flex-wrap">
|
||||
<span className="text-xs text-slate-400">Sync selected to:</span>
|
||||
<select
|
||||
value={targetKey}
|
||||
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [ci]: e.target.value }))}
|
||||
onChange={e => setClusterSyncTargets(prev => ({ ...prev, [clusterKey]: e.target.value }))}
|
||||
className="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-violet-500"
|
||||
>
|
||||
{cluster.groups.map(g => {
|
||||
@@ -441,21 +449,21 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
</select>
|
||||
<button
|
||||
disabled={selection.size === 0}
|
||||
onClick={() => setPendingNearDupSync({ clusterIndex: ci, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
|
||||
onClick={() => setPendingNearDupSync({ clusterKey, targetGroupKey: targetKey, selectedIndices: Array.from(selection) })}
|
||||
className="flex items-center gap-1.5 px-3 py-1 bg-violet-600/20 text-violet-400 hover:bg-violet-600 hover:text-white rounded text-xs font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Layers className="w-3 h-3" />
|
||||
Sync {selection.size} selected
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set(allClusterRows.map(r => r.index)) }))}
|
||||
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set(allClusterRows.map(r => r.index)) }))}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Select all
|
||||
</button>
|
||||
{selection.size > 0 && (
|
||||
<button
|
||||
onClick={() => setClusterSelections(prev => ({ ...prev, [ci]: new Set() }))}
|
||||
onClick={() => setClusterSelections(prev => ({ ...prev, [clusterKey]: new Set() }))}
|
||||
className="text-xs text-slate-500 hover:text-slate-300 transition-colors"
|
||||
>
|
||||
Clear
|
||||
@@ -463,7 +471,6 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Flat product list */}
|
||||
<div className="divide-y divide-violet-500/10">
|
||||
{allClusterRows.map(({ row, index }) => {
|
||||
const isSelected = selection.has(index);
|
||||
@@ -475,17 +482,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
isSelected && "bg-violet-500/10"
|
||||
)}
|
||||
onClick={() => setClusterSelections(prev => {
|
||||
const current = new Set(prev[ci] ?? []);
|
||||
const current = new Set(prev[clusterKey] ?? []);
|
||||
if (current.has(index)) current.delete(index); else current.add(index);
|
||||
return { ...prev, [ci]: current };
|
||||
return { ...prev, [clusterKey]: current };
|
||||
})}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0"
|
||||
readOnly
|
||||
className="rounded border-slate-600 bg-slate-700 text-violet-600 focus:ring-violet-500 shrink-0 pointer-events-none"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -509,11 +515,14 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
)}
|
||||
|
||||
<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)}
|
||||
@@ -525,17 +534,22 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
<span className="font-mono text-sm text-blue-400 bg-blue-400/10 px-2 py-0.5 rounded">
|
||||
Inner: {group.innerDims} cm
|
||||
</span>
|
||||
{hasPending && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-yellow-500 bg-yellow-500/10 px-2 py-0.5 rounded ring-1 ring-yellow-500/20">
|
||||
Pending Validation
|
||||
</span>
|
||||
)}
|
||||
{group.isInconsistent ? (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-amber-500 bg-amber-500/10 px-2 py-0.5 rounded ring-1 ring-amber-500/20">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
Inconsistent
|
||||
</span>
|
||||
) : (
|
||||
) : !hasPending ? (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-emerald-500 bg-emerald-500/10 px-2 py-0.5 rounded ring-1 ring-emerald-500/20">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
Consistent
|
||||
</span>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{group.rows.length} product{group.rows.length !== 1 ? 's' : ''} in this dimension group
|
||||
@@ -584,8 +598,16 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{group.rows.map(({ row, index }) => (
|
||||
<tr key={index} className="hover:bg-slate-700/20 group transition-colors">
|
||||
{group.rows.map(({ row, index }) => {
|
||||
const isPending = rowStatuses[String(row[COLUMNS.ARTICLE_NO])] === 'pending';
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
"hover:bg-slate-700/20 group transition-colors",
|
||||
isPending ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-slate-200">{row[COLUMNS.ARTICLE_NO]}</div>
|
||||
<div className="text-[10px] text-slate-500 truncate max-w-[200px]">{row[COLUMNS.ARTICLE_NAME]}</div>
|
||||
@@ -642,32 +664,41 @@ export function DimensionsView({ data, headers, onEdit, onSaveRow, onCaptureStat
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<button
|
||||
onClick={() => handleFullSync(group, row)}
|
||||
title="FULL SYNC: Apply ALL packaging measures labels to all in group"
|
||||
disabled={syncing?.key === group.key}
|
||||
className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all"
|
||||
className="p-1.5 hover:bg-blue-600/20 text-slate-500 hover:text-blue-400 rounded transition-all opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
{syncing?.key === group.key && syncing?.field === 'all' ? <Loader2 className="w-4 h-4 animate-spin" /> : <RefreshCw className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onRevertRow(String(row[COLUMNS.ARTICLE_NO]))}
|
||||
title="Undo pending changes"
|
||||
className="p-1.5 hover:bg-red-600/20 text-yellow-500 hover:text-red-400 rounded transition-all"
|
||||
>
|
||||
<Undo2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(index)}
|
||||
title="Edit product"
|
||||
className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all"
|
||||
className="p-1.5 hover:bg-slate-600/20 text-slate-500 hover:text-slate-300 rounded transition-all opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredGroups.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-20 bg-slate-800/20 border border-dashed border-slate-700 rounded-xl">
|
||||
|
||||
@@ -127,6 +127,11 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
const generatedText = await generateGemini(prompt, systemPrompt);
|
||||
setFormData(prev => ({ ...prev, [field]: generatedText.trim() }));
|
||||
setGeneratedFields(prev => new Set(prev).add(field));
|
||||
// Focus the textarea so user can edit immediately after AI generation
|
||||
setTimeout(() => {
|
||||
const textarea = document.getElementById(`field-${field}`);
|
||||
if (textarea) textarea.focus();
|
||||
}, 100);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'An error occurred during generation.');
|
||||
} finally {
|
||||
@@ -174,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">
|
||||
@@ -205,8 +214,10 @@ export function EditPanel({ row, rowIndex, onSave, onClose, onCaptureState }: Ed
|
||||
)}
|
||||
</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"
|
||||
@@ -315,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>
|
||||
|
||||
@@ -7,9 +7,10 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
interface MatrixViewProps {
|
||||
data: ExcelRow[];
|
||||
headers: string[];
|
||||
rowStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
export function MatrixView({ data, headers }: MatrixViewProps) {
|
||||
export function MatrixView({ data, headers, rowStatuses }: MatrixViewProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,7 @@ interface PricingViewProps {
|
||||
onSaveRow: (index: number, updatedRow: ExcelRow) => void;
|
||||
onCaptureState: (message: string) => void;
|
||||
onEdit: (index: number) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DetectedCol {
|
||||
@@ -45,7 +46,7 @@ function findCol(headers: string[], ...keywords: string[]): number {
|
||||
);
|
||||
}
|
||||
|
||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit }: PricingViewProps) {
|
||||
export function PricingView({ data, headers, onSaveRow, onCaptureState, onEdit, rowStatuses }: PricingViewProps) {
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all_errors');
|
||||
const [search, setSearch] = useState('');
|
||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null);
|
||||
@@ -454,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 */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ExcelRow, COLUMNS } from '../types';
|
||||
import { Search, Filter, Edit2, ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
@@ -7,14 +7,15 @@ import { ColumnFilterPopover } from './ColumnFilterPopover';
|
||||
interface ProductDescriptionsProps {
|
||||
data: ExcelRow[];
|
||||
onEdit: (index: number) => void;
|
||||
rowStatuses: Record<string, string>;
|
||||
}
|
||||
|
||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingLongAny' | 'missingShortDE' | 'missingShortEN' | 'missingShortAny' | 'complete' | 'incomplete';
|
||||
type TabType = 'all' | 'missingLongDE' | 'missingLongEN' | 'missingShortDE' | 'missingShortEN' | 'complete' | 'incomplete';
|
||||
|
||||
// Description columns that should only have Present/Missing filters
|
||||
const DESCRIPTION_COLUMNS = [COLUMNS.LONG_DE, COLUMNS.LONG_EN, COLUMNS.SHORT_DE, COLUMNS.SHORT_EN];
|
||||
|
||||
export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps) {
|
||||
export function ProductDescriptions({ data, onEdit, rowStatuses }: ProductDescriptionsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [lineFilter, setLineFilter] = useState('');
|
||||
@@ -25,6 +26,17 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
const [page, setPage] = useState(1);
|
||||
const [columnFilters, setColumnFilters] = useState<Record<number, string[]>>({});
|
||||
const [openFilterCol, setOpenFilterCol] = useState<number | null>(null);
|
||||
const [columnWidths, setColumnWidths] = useState<Record<number, number>>({
|
||||
[COLUMNS.ARTICLE_NO]: 100,
|
||||
[COLUMNS.ARTICLE_NAME]: 250,
|
||||
[COLUMNS.LINE]: 80,
|
||||
[COLUMNS.LICENSE]: 120,
|
||||
[COLUMNS.CLASSIFICATION]: 100,
|
||||
[COLUMNS.LONG_DE]: 80,
|
||||
[COLUMNS.LONG_EN]: 80,
|
||||
[COLUMNS.SHORT_DE]: 80,
|
||||
[COLUMNS.SHORT_EN]: 80,
|
||||
});
|
||||
|
||||
const lines = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LINE]).filter(Boolean))), [data]);
|
||||
const licenses = useMemo(() => Array.from(new Set(data.map(r => r[COLUMNS.LICENSE]).filter(Boolean))), [data]);
|
||||
@@ -35,10 +47,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
// Tab filter
|
||||
if (activeTab === 'missingLongDE') result = result.filter(r => !r.row[COLUMNS.LONG_DE]);
|
||||
if (activeTab === 'missingLongEN') result = result.filter(r => !r.row[COLUMNS.LONG_EN]);
|
||||
if (activeTab === 'missingLongAny') result = result.filter(r => !r.row[COLUMNS.LONG_DE] || !r.row[COLUMNS.LONG_EN]);
|
||||
if (activeTab === 'missingShortDE') result = result.filter(r => !r.row[COLUMNS.SHORT_DE]);
|
||||
if (activeTab === 'missingShortEN') result = result.filter(r => !r.row[COLUMNS.SHORT_EN]);
|
||||
if (activeTab === 'missingShortAny') result = result.filter(r => !r.row[COLUMNS.SHORT_DE] || !r.row[COLUMNS.SHORT_EN]);
|
||||
|
||||
if (activeTab === 'complete') result = result.filter(r =>
|
||||
r.row[COLUMNS.LONG_DE] && r.row[COLUMNS.LONG_EN] &&
|
||||
@@ -63,6 +73,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
if (licenseFilter) result = result.filter(r => r.row[COLUMNS.LICENSE] === licenseFilter);
|
||||
|
||||
// Column-specific filters (Excel-like)
|
||||
console.log('[Filter] applying columnFilters:', columnFilters, 'result count before:', result.length);
|
||||
Object.entries(columnFilters).forEach(([colIdx, selectedValues]) => {
|
||||
const col = Number(colIdx);
|
||||
const vals = selectedValues as string[];
|
||||
@@ -71,12 +82,17 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||
result = result.filter(r => {
|
||||
const hasValue = Boolean(r.row[col]);
|
||||
const shouldInclude = vals.includes('Present') && hasValue || vals.includes('Missing') && !hasValue;
|
||||
const shouldInclude = (vals.includes('Present') && hasValue) || (vals.includes('Missing') && !hasValue);
|
||||
return shouldInclude;
|
||||
});
|
||||
} else {
|
||||
// For other columns, use regular value matching
|
||||
result = result.filter(r => vals.includes(String(r.row[col] || '')));
|
||||
const before = result.length;
|
||||
result = result.filter(r => {
|
||||
const cellVal = String(r.row[col] ?? '').trim();
|
||||
return vals.some(v => v.trim() === cellVal);
|
||||
});
|
||||
console.log('[Filter] col', col, 'vals', vals, 'before:', before, 'after:', result.length);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -91,7 +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;
|
||||
@@ -109,6 +125,25 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = (colIndex: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const startX = e.pageX;
|
||||
const startWidth = columnWidths[colIndex] || 100;
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newWidth = Math.max(60, startWidth + (moveEvent.pageX - startX));
|
||||
setColumnWidths(prev => ({ ...prev, [colIndex]: newWidth }));
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
const getUniqueValues = (col: number) => {
|
||||
// For description columns, return only 'Present' and 'Missing'
|
||||
if (DESCRIPTION_COLUMNS.includes(col)) {
|
||||
@@ -120,12 +155,15 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
};
|
||||
|
||||
const toggleColumnFilter = (col: number, value: string) => {
|
||||
console.log('[Filter] toggleColumnFilter called, col:', col, 'value:', JSON.stringify(value));
|
||||
setColumnFilters(prev => {
|
||||
const current = prev[col] || [];
|
||||
const next = current.includes(value)
|
||||
? current.filter(v => v !== value)
|
||||
: [...current, value];
|
||||
return { ...prev, [col]: next };
|
||||
const updated = { ...prev, [col]: next };
|
||||
console.log('[Filter] new columnFilters:', updated);
|
||||
return updated;
|
||||
});
|
||||
setPage(1);
|
||||
};
|
||||
@@ -172,10 +210,8 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
{ id: 'all', label: 'All Products' },
|
||||
{ id: 'missingLongDE', label: 'Missing Long DE' },
|
||||
{ id: 'missingLongEN', label: 'Missing Long EN' },
|
||||
{ id: 'missingLongAny', label: 'Missing Long DE/EN' },
|
||||
{ id: 'missingShortDE', label: 'Missing Short DE' },
|
||||
{ id: 'missingShortEN', label: 'Missing Short EN' },
|
||||
{ id: 'missingShortAny', label: 'Missing Short DE/EN' },
|
||||
{ id: 'complete', label: 'Complete' },
|
||||
{ id: 'incomplete', label: 'Incomplete' },
|
||||
];
|
||||
@@ -242,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>
|
||||
{[
|
||||
@@ -258,13 +294,14 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
].map(({ col, label }) => (
|
||||
<th
|
||||
key={col}
|
||||
className="px-4 py-3 font-medium transition-colors select-none group relative"
|
||||
className="px-4 py-3 font-medium transition-colors select-none group relative border-r border-slate-700/30"
|
||||
style={{ width: columnWidths[col] || 'auto', minWidth: columnWidths[col] || 'auto' }}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white" onClick={() => handleSort(col)}>
|
||||
<div className="flex items-center justify-between gap-1 overflow-hidden">
|
||||
<div className="flex items-center gap-1 cursor-pointer hover:text-white truncate" onClick={() => handleSort(col)}>
|
||||
{label}
|
||||
{sortCol === col && (
|
||||
sortDesc ? <ChevronDown className="w-4 h-4" /> : <ChevronUp className="w-4 h-4" />
|
||||
sortDesc ? <ChevronDown className="w-4 h-4 shrink-0" /> : <ChevronUp className="w-4 h-4 shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
@@ -273,7 +310,7 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
setOpenFilterCol(openFilterCol === col ? null : col);
|
||||
}}
|
||||
className={cn(
|
||||
"p-1 rounded hover:bg-slate-700 transition-colors",
|
||||
"p-1 rounded hover:bg-slate-700 transition-colors shrink-0",
|
||||
(columnFilters[col]?.length || 0) > 0 ? "text-blue-400 bg-blue-400/10" : "text-slate-500 opacity-0 group-hover:opacity-100"
|
||||
)}
|
||||
>
|
||||
@@ -281,6 +318,12 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resizer handle */}
|
||||
<div
|
||||
onMouseDown={(e) => handleResize(col, e)}
|
||||
className="absolute right-0 top-0 bottom-0 w-1 cursor-col-resize hover:bg-blue-500/50 group-hover:bg-slate-700/50 transition-colors z-20"
|
||||
/>
|
||||
|
||||
{openFilterCol === col && (
|
||||
<ColumnFilterPopover
|
||||
uniqueValues={getUniqueValues(col)}
|
||||
@@ -300,17 +343,27 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
<th className="px-4 py-3 font-medium text-right">Actions</th>
|
||||
<th className="px-4 py-3 font-medium text-right" style={{ width: 100 }}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/50">
|
||||
{paginatedData.map(({ row, index }) => (
|
||||
<tr key={index} className={cn("transition-colors", getRowColor(row))}>
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs">{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-4 py-3 font-medium text-white max-w-[200px] truncate" title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs">{row[COLUMNS.LINE]}</td>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs truncate max-w-[120px]" title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||
<td className="px-4 py-3">
|
||||
{paginatedData.map(({ row, index }) => {
|
||||
const saveStatus = rowStatuses[String(row[COLUMNS.ARTICLE_NO])];
|
||||
return (
|
||||
<tr
|
||||
key={index}
|
||||
className={cn(
|
||||
"transition-colors",
|
||||
getRowColor(row),
|
||||
saveStatus === 'error' ? "bg-red-400/20 border-l-4 border-l-red-500" :
|
||||
saveStatus === 'pending' ? "bg-yellow-400/20 border-l-4 border-l-yellow-400" : ""
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 font-mono text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NO] }}>{row[COLUMNS.ARTICLE_NO]}</td>
|
||||
<td className="px-4 py-3 font-medium text-white truncate" style={{ width: columnWidths[COLUMNS.ARTICLE_NAME] }} title={row[COLUMNS.ARTICLE_NAME]}>{row[COLUMNS.ARTICLE_NAME]}</td>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LINE] }}>{row[COLUMNS.LINE]}</td>
|
||||
<td className="px-4 py-3 text-slate-300 text-xs truncate" style={{ width: columnWidths[COLUMNS.LICENSE] }} title={row[COLUMNS.LICENSE]}>{row[COLUMNS.LICENSE] || '—'}</td>
|
||||
<td className="px-4 py-3 truncate" style={{ width: columnWidths[COLUMNS.CLASSIFICATION] }}>
|
||||
<span className={cn(
|
||||
"px-2 py-0.5 rounded text-[10px] font-bold border",
|
||||
String(row[COLUMNS.CLASSIFICATION]).includes('OOC')
|
||||
@@ -320,10 +373,10 @@ export function ProductDescriptions({ data, onEdit }: ProductDescriptionsProps)
|
||||
{row[COLUMNS.CLASSIFICATION] || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
|
||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
|
||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
||||
<td className="px-4 py-3"><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
|
||||
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_DE] }}><Badge content={row[COLUMNS.LONG_DE]} row={row} /></td>
|
||||
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.LONG_EN] }}><Badge content={row[COLUMNS.LONG_EN]} row={row} /></td>
|
||||
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_DE] }}><Badge content={row[COLUMNS.SHORT_DE]} row={row} /></td>
|
||||
<td className="px-4 py-3" style={{ width: columnWidths[COLUMNS.SHORT_EN] }}><Badge content={row[COLUMNS.SHORT_EN]} row={row} /></td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<button
|
||||
onClick={() => onEdit(index)}
|
||||
@@ -334,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">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { FileText, Table, Box, DollarSign, Package } from 'lucide-react';
|
||||
import { FileText, Table, Box, DollarSign, Package, Clock, History } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: string;
|
||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing') => void;
|
||||
setActiveModule: (m: 'descriptions' | 'article_details' | 'matrix' | 'dimensions' | 'pricing' | 'pending_validation' | 'history') => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
||||
@@ -14,6 +14,8 @@ export function Sidebar({ activeModule, setActiveModule }: SidebarProps) {
|
||||
{ id: 'article_details', label: 'Article Details', icon: Package },
|
||||
{ id: 'dimensions', label: 'Dimensions', icon: Box },
|
||||
{ id: 'pricing', label: 'Pricing & Units', icon: DollarSign },
|
||||
{ id: 'pending_validation', label: 'Pending Validation', icon: Clock },
|
||||
{ id: 'history', label: 'Change History', icon: History },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
|
||||
+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