import / export feature

This commit is contained in:
lasse 2025-05-28 00:10:54 +02:00
parent 2846bcbb1c
commit 03d80b99dc
7 changed files with 652 additions and 8 deletions

View File

@ -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",

View File

@ -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"
}
}

View File

@ -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>
);

View File

@ -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"

View File

@ -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';

View File

@ -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';

View 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;