import / export feature
This commit is contained in:
parent
2846bcbb1c
commit
03d80b99dc
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -9,9 +9,11 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"axios": "^1.6.2",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
@ -4279,6 +4281,15 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/papaparse": {
|
||||
"version": "5.3.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz",
|
||||
"integrity": "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
|
||||
@ -13720,6 +13731,12 @@
|
||||
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/papaparse": {
|
||||
"version": "5.5.3",
|
||||
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
|
||||
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/param-case": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",
|
||||
|
||||
@ -4,15 +4,17 @@
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
"@types/papaparse": "^5.3.16",
|
||||
"@types/react": "^18.2.45",
|
||||
"@types/react-dom": "^18.2.18",
|
||||
"axios": "^1.6.2",
|
||||
"papaparse": "^5.5.3",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.20.1",
|
||||
"react-scripts": "5.0.1",
|
||||
"typescript": "^4.9.5",
|
||||
"web-vitals": "^3.5.0",
|
||||
"axios": "^1.6.2"
|
||||
"web-vitals": "^3.5.0"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts start",
|
||||
@ -40,8 +42,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.8",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.32"
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ShopList from './components/ShopList';
|
||||
@ -7,8 +7,9 @@ import ShoppingEventList from './components/ShoppingEventList';
|
||||
import BrandList from './components/BrandList';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import GroceryCategoryList from './components/GroceryCategoryList';
|
||||
import ImportExportModal from './components/ImportExportModal';
|
||||
|
||||
function Navigation() {
|
||||
function Navigation({ onImportExportClick }: { onImportExportClick: () => void }) {
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
@ -91,6 +92,17 @@ function Navigation() {
|
||||
Categories
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={onImportExportClick}
|
||||
className="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-100 hover:text-white hover:bg-blue-700 rounded-md transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
|
||||
</svg>
|
||||
Import / Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@ -98,10 +110,18 @@ function Navigation() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [showImportExportModal, setShowImportExportModal] = useState(false);
|
||||
|
||||
const handleDataChanged = () => {
|
||||
// This will be called when data is imported, but since we're at the app level,
|
||||
// individual components will need to handle their own refresh
|
||||
// The modal will close automatically after successful import
|
||||
};
|
||||
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
<Navigation onImportExportClick={() => setShowImportExportModal(true)} />
|
||||
<main className="py-10">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Routes>
|
||||
@ -115,6 +135,12 @@ function App() {
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ImportExportModal
|
||||
isOpen={showImportExportModal}
|
||||
onClose={() => setShowImportExportModal(false)}
|
||||
onDataChanged={handleDataChanged}
|
||||
/>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
|
||||
@ -100,7 +100,7 @@ const Dashboard: React.FC = () => {
|
||||
<h2 className="text-lg font-medium text-gray-900">Quick Actions</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/shopping-events?add=true')}
|
||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { GroceryCategory } from '../types';
|
||||
import { groceryCategoryApi } from '../services/api';
|
||||
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Grocery } from '../types';
|
||||
import { groceryApi } from '../services/api';
|
||||
import AddGroceryModal from './AddGroceryModal';
|
||||
|
||||
597
frontend/src/components/ImportExportModal.tsx
Normal file
597
frontend/src/components/ImportExportModal.tsx
Normal file
@ -0,0 +1,597 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Papa from 'papaparse';
|
||||
import { Brand, Grocery, GroceryCategory, Product } from '../types';
|
||||
import { brandApi, groceryApi, groceryCategoryApi, productApi } from '../services/api';
|
||||
|
||||
interface ImportExportModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onDataChanged: () => void;
|
||||
}
|
||||
|
||||
type EntityType = 'brands' | 'groceries' | 'categories' | 'products';
|
||||
|
||||
interface ImportResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose, onDataChanged }) => {
|
||||
const [activeTab, setActiveTab] = useState<'export' | 'import'>('export');
|
||||
const [selectedEntity, setSelectedEntity] = useState<EntityType>('brands');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importPreview, setImportPreview] = useState<any[]>([]);
|
||||
const [importResult, setImportResult] = useState<ImportResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset state when modal closes
|
||||
setActiveTab('export');
|
||||
setSelectedEntity('brands');
|
||||
setImportFile(null);
|
||||
setImportPreview([]);
|
||||
setImportResult(null);
|
||||
setMessage('');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleExport = async () => {
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
let data: any[] = [];
|
||||
let filename = '';
|
||||
|
||||
switch (selectedEntity) {
|
||||
case 'brands':
|
||||
const brandsResponse = await brandApi.getAll();
|
||||
data = brandsResponse.data.map((brand: Brand) => ({
|
||||
id: brand.id,
|
||||
name: brand.name,
|
||||
created_at: brand.created_at,
|
||||
updated_at: brand.updated_at
|
||||
}));
|
||||
filename = 'brands.csv';
|
||||
break;
|
||||
|
||||
case 'categories':
|
||||
const categoriesResponse = await groceryCategoryApi.getAll();
|
||||
data = categoriesResponse.data.map((category: GroceryCategory) => ({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
created_at: category.created_at,
|
||||
updated_at: category.updated_at
|
||||
}));
|
||||
filename = 'grocery_categories.csv';
|
||||
break;
|
||||
|
||||
case 'groceries':
|
||||
const groceriesResponse = await groceryApi.getAll();
|
||||
data = groceriesResponse.data.map((grocery: Grocery) => ({
|
||||
id: grocery.id,
|
||||
name: grocery.name,
|
||||
category_id: grocery.category.id,
|
||||
category_name: grocery.category.name,
|
||||
created_at: grocery.created_at,
|
||||
updated_at: grocery.updated_at
|
||||
}));
|
||||
filename = 'groceries.csv';
|
||||
break;
|
||||
|
||||
case 'products':
|
||||
const productsResponse = await productApi.getAll();
|
||||
data = productsResponse.data.map((product: Product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
organic: product.organic,
|
||||
grocery_id: product.grocery.id,
|
||||
grocery_name: product.grocery.name,
|
||||
category_id: product.grocery.category.id,
|
||||
category_name: product.grocery.category.name,
|
||||
brand_id: product.brand?.id || null,
|
||||
brand_name: product.brand?.name || null,
|
||||
weight: product.weight,
|
||||
weight_unit: product.weight_unit,
|
||||
created_at: product.created_at,
|
||||
updated_at: product.updated_at
|
||||
}));
|
||||
filename = 'products.csv';
|
||||
break;
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
setMessage(`No ${selectedEntity} found to export.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert to CSV
|
||||
const csv = Papa.unparse(data);
|
||||
|
||||
// Create and trigger download
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', filename);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
setMessage(`Successfully exported ${data.length} ${selectedEntity} to ${filename}`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Export error:', error);
|
||||
setMessage(`Failed to export ${selectedEntity}. Please try again.`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.toLowerCase().endsWith('.csv')) {
|
||||
setMessage('Please select a CSV file.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
setImportFile(file);
|
||||
setImportResult(null);
|
||||
|
||||
// Parse CSV for preview
|
||||
Papa.parse(file, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
if (results.errors.length > 0) {
|
||||
setMessage('Error parsing CSV file. Please check the format.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
return;
|
||||
}
|
||||
setImportPreview(results.data.slice(0, 5)); // Show first 5 rows
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('CSV parse error:', error);
|
||||
setMessage('Error parsing CSV file. Please check the format.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const validateImportData = (data: any[]): { valid: any[], errors: string[] } => {
|
||||
const valid: any[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
data.forEach((row, index) => {
|
||||
const rowNum = index + 1;
|
||||
|
||||
if (!row.name || typeof row.name !== 'string' || row.name.trim().length === 0) {
|
||||
errors.push(`Row ${rowNum}: Name is required and must be a non-empty string`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEntity === 'groceries') {
|
||||
if (!row.category_name || typeof row.category_name !== 'string' || row.category_name.trim().length === 0) {
|
||||
errors.push(`Row ${rowNum}: Category name is required for groceries`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEntity === 'products') {
|
||||
if (!row.grocery_name || typeof row.grocery_name !== 'string' || row.grocery_name.trim().length === 0) {
|
||||
errors.push(`Row ${rowNum}: Grocery name is required for products`);
|
||||
return;
|
||||
}
|
||||
if (row.organic !== undefined && typeof row.organic !== 'boolean' && row.organic !== 'true' && row.organic !== 'false') {
|
||||
errors.push(`Row ${rowNum}: Organic must be true/false if provided`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
valid.push({
|
||||
name: row.name.trim(),
|
||||
category_name: selectedEntity === 'groceries' ? row.category_name.trim() : undefined,
|
||||
grocery_name: selectedEntity === 'products' ? row.grocery_name.trim() : undefined,
|
||||
organic: selectedEntity === 'products' ? (row.organic === 'true' || row.organic === true) : undefined,
|
||||
brand_name: selectedEntity === 'products' && row.brand_name ? row.brand_name.trim() : undefined,
|
||||
weight: selectedEntity === 'products' && row.weight ? parseFloat(row.weight) : undefined,
|
||||
weight_unit: selectedEntity === 'products' && row.weight_unit ? row.weight_unit.trim() : undefined
|
||||
});
|
||||
});
|
||||
|
||||
return { valid, errors };
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!importFile) return;
|
||||
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
setImportResult(null);
|
||||
|
||||
try {
|
||||
// Parse the entire file
|
||||
Papa.parse(importFile, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: async (results) => {
|
||||
try {
|
||||
const { valid, errors } = validateImportData(results.data);
|
||||
|
||||
if (errors.length > 0 && valid.length === 0) {
|
||||
setMessage(`Validation failed: ${errors.join(', ')}`);
|
||||
setTimeout(() => setMessage(''), 5000);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const importErrors: string[] = [...errors];
|
||||
|
||||
// Get categories for grocery import
|
||||
let categories: GroceryCategory[] = [];
|
||||
if (selectedEntity === 'groceries') {
|
||||
const categoriesResponse = await groceryCategoryApi.getAll();
|
||||
categories = categoriesResponse.data;
|
||||
}
|
||||
|
||||
// Get groceries and brands for product import
|
||||
let groceries: Grocery[] = [];
|
||||
let brands: Brand[] = [];
|
||||
if (selectedEntity === 'products') {
|
||||
const groceriesResponse = await groceryApi.getAll();
|
||||
groceries = groceriesResponse.data;
|
||||
const brandsResponse = await brandApi.getAll();
|
||||
brands = brandsResponse.data;
|
||||
}
|
||||
|
||||
// Import valid records
|
||||
for (const item of valid) {
|
||||
try {
|
||||
switch (selectedEntity) {
|
||||
case 'brands':
|
||||
await brandApi.create({ name: item.name });
|
||||
break;
|
||||
|
||||
case 'categories':
|
||||
await groceryCategoryApi.create({ name: item.name });
|
||||
break;
|
||||
|
||||
case 'groceries':
|
||||
const category = categories.find(c => c.name.toLowerCase() === item.category_name.toLowerCase());
|
||||
if (!category) {
|
||||
failedCount++;
|
||||
importErrors.push(`Grocery "${item.name}": Category "${item.category_name}" not found`);
|
||||
continue;
|
||||
}
|
||||
await groceryApi.create({
|
||||
name: item.name,
|
||||
category_id: category.id
|
||||
});
|
||||
break;
|
||||
|
||||
case 'products':
|
||||
const grocery = groceries.find(g => g.name.toLowerCase() === item.grocery_name.toLowerCase());
|
||||
if (!grocery) {
|
||||
failedCount++;
|
||||
importErrors.push(`Product "${item.name}": Grocery "${item.grocery_name}" not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
let brandId = null;
|
||||
if (item.brand_name) {
|
||||
const brand = brands.find(b => b.name.toLowerCase() === item.brand_name.toLowerCase());
|
||||
if (!brand) {
|
||||
failedCount++;
|
||||
importErrors.push(`Product "${item.name}": Brand "${item.brand_name}" not found`);
|
||||
continue;
|
||||
}
|
||||
brandId = brand.id;
|
||||
}
|
||||
|
||||
await productApi.create({
|
||||
name: item.name,
|
||||
organic: item.organic || false,
|
||||
grocery_id: grocery.id,
|
||||
brand_id: brandId || undefined,
|
||||
weight: item.weight,
|
||||
weight_unit: item.weight_unit || ''
|
||||
});
|
||||
break;
|
||||
}
|
||||
successCount++;
|
||||
} catch (error: any) {
|
||||
failedCount++;
|
||||
const errorMsg = error.response?.data?.detail || error.message || 'Unknown error';
|
||||
importErrors.push(`${item.name}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
setImportResult({
|
||||
success: successCount,
|
||||
failed: failedCount,
|
||||
errors: importErrors
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
onDataChanged(); // Refresh the data in parent components
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import processing error:', error);
|
||||
setMessage('Error processing import. Please try again.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error('CSV parse error:', error);
|
||||
setMessage('Error parsing CSV file. Please check the format.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Import error:', error);
|
||||
setMessage('Failed to import data. Please try again.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getExpectedFormat = () => {
|
||||
switch (selectedEntity) {
|
||||
case 'brands':
|
||||
case 'categories':
|
||||
return 'name';
|
||||
case 'groceries':
|
||||
return 'name,category_name';
|
||||
case 'products':
|
||||
return 'name,grocery_name (organic,brand_name,weight,weight_unit are optional)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-10 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white max-h-[90vh] overflow-y-auto">
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
Import / Export Data
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 px-4 py-3 rounded ${
|
||||
message.includes('Error') || message.includes('Failed')
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('export')}
|
||||
className={`py-2 px-4 text-sm font-medium ${
|
||||
activeTab === 'export'
|
||||
? 'border-b-2 border-blue-500 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Export Data
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('import')}
|
||||
className={`py-2 px-4 text-sm font-medium ${
|
||||
activeTab === 'import'
|
||||
? 'border-b-2 border-blue-500 text-blue-600'
|
||||
: 'text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
>
|
||||
Import Data
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Entity Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Data Type
|
||||
</label>
|
||||
<select
|
||||
value={selectedEntity}
|
||||
onChange={(e) => setSelectedEntity(e.target.value as EntityType)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="brands">Brands</option>
|
||||
<option value="categories">Grocery Categories</option>
|
||||
<option value="groceries">Groceries</option>
|
||||
<option value="products">Products</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Export Tab */}
|
||||
{activeTab === 'export' && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
|
||||
<h4 className="font-medium text-blue-900 mb-2">Export Information</h4>
|
||||
<p className="text-sm text-blue-700 mb-2">
|
||||
This will download all {selectedEntity} as a CSV file that you can open in Excel or other spreadsheet applications.
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>Exported fields:</strong> ID, name, {selectedEntity === 'groceries' ? 'category_id, category_name, ' : selectedEntity === 'products' ? 'organic, grocery_id, grocery_name, category_id, category_name, brand_id, brand_name, weight, weight_unit, ' : ''}created_at, updated_at
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Exporting...' : `Export ${selectedEntity}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Tab */}
|
||||
{activeTab === 'import' && (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
|
||||
<h4 className="font-medium text-yellow-900 mb-2">Import Requirements</h4>
|
||||
<p className="text-sm text-yellow-700 mb-2">
|
||||
CSV file must have the following <strong>required</strong> columns: <code className="bg-yellow-100 px-1 rounded">{getExpectedFormat()}</code>
|
||||
</p>
|
||||
<p className="text-sm text-yellow-700 mb-2">
|
||||
<strong>Note:</strong> ID, created_at, and updated_at fields are optional for import and will be ignored if present.
|
||||
</p>
|
||||
{selectedEntity === 'groceries' && (
|
||||
<p className="text-sm text-yellow-700">
|
||||
<strong>Groceries:</strong> Category names must match existing grocery categories exactly.
|
||||
</p>
|
||||
)}
|
||||
{selectedEntity === 'products' && (
|
||||
<p className="text-sm text-yellow-700">
|
||||
<strong>Products:</strong> Grocery names must match existing groceries exactly. Brand names (if provided) must match existing brands exactly.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select CSV File
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
{importPreview.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-2">Preview (first 5 rows)</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 border border-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
{Object.keys(importPreview[0]).map(key => (
|
||||
<th key={key} className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-r border-gray-300">
|
||||
{key}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{importPreview.map((row, index) => (
|
||||
<tr key={index}>
|
||||
{Object.values(row).map((value: any, cellIndex) => (
|
||||
<td key={cellIndex} className="px-4 py-2 text-sm text-gray-900 border-r border-gray-300">
|
||||
{value}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Results */}
|
||||
{importResult && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Import Results</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<p className="text-green-600">✓ Successfully imported: {importResult.success}</p>
|
||||
<p className="text-red-600">✗ Failed to import: {importResult.failed}</p>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<p className="font-medium text-gray-700">Errors:</p>
|
||||
<div className="max-h-32 overflow-y-auto bg-red-50 border border-red-200 rounded p-2 mt-1">
|
||||
{importResult.errors.map((error, index) => (
|
||||
<p key={index} className="text-xs text-red-700">{error}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Button */}
|
||||
{importFile && (
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Importing...' : `Import ${selectedEntity}`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Close Button */}
|
||||
<div className="flex justify-end pt-6 border-t border-gray-200 mt-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImportExportModal;
|
||||
Loading…
x
Reference in New Issue
Block a user