diff --git a/frontend/src/MaterialsList.css b/frontend/src/MaterialsList.css index 8c1c12c..6f0fead 100644 --- a/frontend/src/MaterialsList.css +++ b/frontend/src/MaterialsList.css @@ -474,3 +474,75 @@ min-width: 800px; } } + +/* Pagination Styles */ +.pagination-controls { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + justify-content: center; + flex-wrap: wrap; +} + +.page-btn { + padding: 8px 16px; + border: 1px solid #ddd; + background: white; + color: #333; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s; + font-size: 14px; + min-width: 80px; +} + +.page-btn:hover:not(:disabled) { + background: #f0f0f0; + border-color: #bbb; +} + +.page-btn:disabled { + background: #f5f5f5; + color: #999; + cursor: not-allowed; + border-color: #eee; +} + +.page-info { + padding: 8px 16px; + color: #666; + font-weight: 500; + background: #f8f9fa; + border-radius: 4px; + border: 1px solid #e9ecef; +} + +.items-per-page { + padding: 8px 12px; + border: 1px solid #ddd; + border-radius: 4px; + background: white; + color: #333; + font-size: 14px; + cursor: pointer; +} + +.items-per-page:focus { + outline: none; + border-color: #3498db; + box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2); +} + +/* Bygma Badge */ +.bygma-badge { + display: inline-block; + padding: 4px 8px; + background: #ff6b35; + color: white; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} diff --git a/frontend/src/MaterialsList.js b/frontend/src/MaterialsList.js index 3fe1cb9..32b5fd8 100644 --- a/frontend/src/MaterialsList.js +++ b/frontend/src/MaterialsList.js @@ -12,6 +12,12 @@ const MaterialsList = ({ apiBaseUrl }) => { const [sortOrder, setSortOrder] = useState('asc'); const [editingMaterial, setEditingMaterial] = useState(null); const [showNewMaterialForm, setShowNewMaterialForm] = useState(false); + // Pagination state + const [currentPage, setCurrentPage] = useState(1); + const [itemsPerPage, setItemsPerPage] = useState(50); + const [totalItems, setTotalItems] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [newMaterialForm, setNewMaterialForm] = useState({ name: '', category: '', @@ -29,25 +35,52 @@ const MaterialsList = ({ apiBaseUrl }) => { description: '' }); - const loadMaterials = useCallback(async () => { + const loadMaterials = useCallback(async (page = 1, search = '', category = '') => { setLoading(true); setError(''); try { - const response = await fetch(`${apiBaseUrl}/api/pricing/materials`); + // Build query parameters + const params = new URLSearchParams({ + includeBygma: 'true', + limit: itemsPerPage.toString(), + page: page.toString() + }); + + if (search) params.append('search', search); + if (category) params.append('category', category); + + const response = await fetch(`${apiBaseUrl}/api/pricing/materials?${params}`); if (!response.ok) { throw new Error('Failed to load materials'); } const data = await response.json(); - // API returns array directly, not wrapped in an object - setMaterials(Array.isArray(data) ? data : []); - console.log('Loaded materials:', data); + + // Handle the new paginated response format + if (data.data && Array.isArray(data.data)) { + setMaterials(data.data); + setTotalItems(data.pagination?.total || 0); + setTotalPages(data.pagination?.pages || 0); + setCurrentPage(data.pagination?.page || 1); + console.log('Loaded materials:', data.data.length, 'out of total:', data.pagination?.total); + } else if (Array.isArray(data)) { + // Fallback for old format + setMaterials(data); + setTotalItems(data.length); + setTotalPages(1); + setCurrentPage(1); + } else { + setMaterials([]); + setTotalItems(0); + setTotalPages(0); + setCurrentPage(1); + } } catch (err) { setError('Fejl ved indlæsning af materialer: ' + err.message); console.error('Error loading materials:', err); } finally { setLoading(false); } - }, [apiBaseUrl]); + }, [apiBaseUrl, itemsPerPage]); const loadCategories = useCallback(async () => { try { @@ -63,9 +96,9 @@ const MaterialsList = ({ apiBaseUrl }) => { }, [apiBaseUrl]); useEffect(() => { - loadMaterials(); + loadMaterials(1, searchTerm, selectedCategory); loadCategories(); - }, [loadMaterials, loadCategories]); + }, [searchTerm, selectedCategory, itemsPerPage, loadMaterials, loadCategories]); const addNewMaterial = async () => { if (!newMaterialForm.name || !newMaterialForm.unit_price) { @@ -196,28 +229,18 @@ const MaterialsList = ({ apiBaseUrl }) => { } }; - // Filter and sort materials - const filteredAndSortedMaterials = materials - .filter(material => { - const matchesSearch = searchTerm === '' || - material.name.toLowerCase().includes(searchTerm.toLowerCase()) || - material.description?.toLowerCase().includes(searchTerm.toLowerCase()) || - material.supplier_name?.toLowerCase().includes(searchTerm.toLowerCase()); - - const matchesCategory = selectedCategory === '' || material.category === selectedCategory; - - return matchesSearch && matchesCategory; - }) + // Sort materials (filtering now happens server-side) + const sortedMaterials = materials .sort((a, b) => { - let aValue = a[sortBy]; - let bValue = b[sortBy]; + let aValue = a[sortBy] || a.name; // fallback to name for Bygma products + let bValue = b[sortBy] || b.name; if (sortBy === 'unit_price') { aValue = parseFloat(aValue) || 0; bValue = parseFloat(bValue) || 0; } else { - aValue = (aValue || '').toString().toLowerCase(); - bValue = (bValue || '').toString().toLowerCase(); + aValue = aValue.toString().toLowerCase(); + bValue = bValue.toString().toLowerCase(); } if (sortOrder === 'asc') { @@ -245,23 +268,26 @@ const MaterialsList = ({ apiBaseUrl }) => { type="text" placeholder="Søg i materialer..." value={searchTerm} - onChange={(e) => setSearchTerm(e.target.value)} + onChange={(e) => { + setSearchTerm(e.target.value); + setCurrentPage(1); // Reset to first page when searching + }} className="search-input" /> @@ -292,7 +318,7 @@ const MaterialsList = ({ apiBaseUrl }) => { {showNewMaterialForm ? 'Annuller' : '+ Nyt Materiale'} + + + Side {currentPage} af {totalPages} + + + + + + + )} {loading ? ( @@ -388,7 +469,7 @@ const MaterialsList = ({ apiBaseUrl }) => { ) : (
- {filteredAndSortedMaterials.length === 0 ? ( + {sortedMaterials.length === 0 ? (

📭 Ingen materialer fundet

{(searchTerm || selectedCategory) && ( @@ -410,7 +491,7 @@ const MaterialsList = ({ apiBaseUrl }) => { - {filteredAndSortedMaterials.map((material) => ( + {sortedMaterials.map((material) => ( {editingMaterial === material.id ? ( // Edit mode @@ -488,15 +569,23 @@ const MaterialsList = ({ apiBaseUrl }) => { ) : ( // View mode <> - {material.name} + + {material.name || material.product_name} + - {material.category || '-'} + + {material.category || material.subcategory || '-'} + + + + {material.unit || material.sales_unit || '-'} - {material.unit} - {formatCurrency(material.unit_price)} + {formatCurrency(material.unit_price || material.price)} + + + {material.supplier_name || (material.source === 'bygma' ? 'Bygma' : '-')} - {material.supplier_name || '-'} {material.description ? (material.description.length > 40 ? @@ -506,20 +595,29 @@ const MaterialsList = ({ apiBaseUrl }) => { } - - + {material.source !== 'bygma' && ( + <> + + + + )} + {material.source === 'bygma' && ( + + 🏗️ Bygma + + )} )} diff --git a/unified-server.js b/unified-server.js index b01c48b..899bfd6 100644 --- a/unified-server.js +++ b/unified-server.js @@ -1980,28 +1980,85 @@ app.get('/api/real-project-types', async (req, res) => { app.get('/api/pricing/materials', async (req, res) => { try { const category = req.query.category; - let query = ` - SELECT - mp.id, - mp.name, - mp.category, - mp.unit, - mp.price as unit_price, - mp.supplier_name, - mp.parse_log - FROM material_prices mp - WHERE mp.is_active = 1 - `; - const params = []; + const search = req.query.search || ''; + const limit = parseInt(req.query.limit) || 100; + const offset = parseInt(req.query.offset) || 0; + const includeBuiltIn = req.query.includeBuiltIn !== 'false'; // default true + const includeBygma = req.query.includeBygma !== 'false'; // default true - if (category) { - query += ' AND mp.category = ?'; - params.push(category); + let materials = []; + + // Hent indbyggede materialer fra material_prices hvis ønsket + if (includeBuiltIn) { + let builtInQuery = ` + SELECT + mp.id, + mp.name, + mp.category, + mp.unit, + mp.price as unit_price, + mp.supplier_name, + mp.parse_log, + 'built_in' as source, + 1 as is_active + FROM material_prices mp + WHERE mp.is_active = 1 + `; + const builtInParams = []; + + if (category) { + builtInQuery += ' AND mp.category = ?'; + builtInParams.push(category); + } + + if (search) { + builtInQuery += ' AND mp.name LIKE ?'; + builtInParams.push(`%${search}%`); + } + + const builtInMaterials = await databaseService.query(builtInQuery, builtInParams); + materials = materials.concat(builtInMaterials); } - query += ' ORDER BY mp.name'; + // Hent Bygma materialer hvis ønsket + if (includeBygma) { + let bygmaQuery = ` + SELECT + bp.id, + bp.tekst as name, + bp.varegrp as category, + bp.enhed as unit, + bp.current_netto_pris as unit_price, + 'Bygma' as supplier_name, + CONCAT('VareNr: ', bp.vareNr, ' | Gruppe: ', bp.varegrp) as parse_log, + 'bygma' as source, + bp.is_active, + bp.vareNr, + bp.current_brutto_pris, + bp.last_seen + FROM bygma_products bp + WHERE bp.is_active = 1 + `; + const bygmaParams = []; + + if (category) { + bygmaQuery += ' AND bp.varegrp = ?'; + bygmaParams.push(category); + } + + if (search) { + bygmaQuery += ' AND (bp.tekst LIKE ? OR bp.vareNr LIKE ?)'; + bygmaParams.push(`%${search}%`, `%${search}%`); + } + + bygmaQuery += ` ORDER BY bp.vareNr LIMIT ${limit} OFFSET ${offset}`; + + const bygmaMaterials = await databaseService.query(bygmaQuery, bygmaParams); + materials = materials.concat(bygmaMaterials); + } - const materials = await databaseService.query(query, params); + // Sortér samlet resultat + materials.sort((a, b) => a.name.localeCompare(b.name)); // Process materials to extract data from parse_log if needed const processedMaterials = materials.map(material => { @@ -2010,6 +2067,24 @@ app.get('/api/pricing/materials', async (req, res) => { let unit = material.unit; let description = ''; + // Handle Bygma products differently + if (material.source === 'bygma') { + return { + id: material.id, + name: material.name || 'Ukendt Bygma produkt', + category: material.category || 'bygma', + unit: material.unit || 'stk', + unit_price: parseFloat(material.unit_price || 0), + brutto_price: parseFloat(material.current_brutto_pris || 0), + supplier_name: 'Bygma', + description: material.parse_log || '', + source: 'bygma', + vareNr: material.vareNr, + last_seen: material.last_seen + }; + } + + // Handle built-in materials // If name is null, try to parse from parse_log if (!name && material.parse_log) { try { @@ -2028,13 +2103,32 @@ app.get('/api/pricing/materials', async (req, res) => { name: name || 'Ukendt materiale', category: category || 'øvrige', unit: unit || 'stk', - unit_price: parseFloat(material.unit_price), + unit_price: parseFloat(material.unit_price || 0), supplier_name: material.supplier_name || '', - description: description + description: description, + source: 'built_in' }; }); - res.json(processedMaterials); + // Hent også total count for pagination + const totalCountQuery = ` + SELECT + (SELECT COUNT(*) FROM material_prices WHERE is_active = 1 ${category ? 'AND category = ?' : ''}) + + (SELECT COUNT(*) FROM bygma_products WHERE is_active = 1 ${category ? 'AND varegrp = ?' : ''}) as total + `; + const countParams = category ? [category, category] : []; + const [countResult] = await databaseService.query(totalCountQuery, countParams); + + res.json({ + success: true, + data: processedMaterials, + pagination: { + total: countResult.total, + limit: limit, + offset: offset, + hasMore: (offset + processedMaterials.length) < countResult.total + } + }); } catch (error) { console.error('Error loading materials:', error); res.status(500).json({ error: 'Fejl ved indlæsning af materialer' }); @@ -2184,7 +2278,7 @@ app.get('/api/pricing/bygma-products', async (req, res) => { try { const search = req.query.search || ''; const varegrp = req.query.varegrp || ''; - const limit = parseInt(req.query.limit) || 50; + const limit = parseInt(req.query.limit) || 100; const offset = parseInt(req.query.offset) || 0; let query = ` @@ -2197,6 +2291,7 @@ app.get('/api/pricing/bygma-products', async (req, res) => { current_netto_pris, current_brutto_pris, last_seen, + is_active, (SELECT COUNT(*) FROM bygma_materials_mapping WHERE bygma_product_id = bygma_products.id) as is_mapped FROM bygma_products WHERE is_active = 1