feat: Add pagination and Bygma product handling to materials API and UI

This commit is contained in:
2025-09-18 23:47:52 +02:00
parent fbac632861
commit cd6cece9da
3 changed files with 346 additions and 81 deletions
+72
View File
@@ -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;
}
+157 -59
View File
@@ -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"
/>
</label>
<label>
<span>Kategori:</span>
<select value={selectedCategory} onChange={(e) => setSelectedCategory(e.target.value)}>
<option value="">Alle kategorier ({materials.length})</option>
{categories.map(category => {
const count = materials.filter(m => m.category === category).length;
return (
<option key={category} value={category}>
{category} ({count})
</option>
);
})}
<select value={selectedCategory} onChange={(e) => {
setSelectedCategory(e.target.value);
setCurrentPage(1); // Reset to first page when filtering
}}>
<option value="">Alle kategorier</option>
{categories.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</label>
@@ -292,7 +318,7 @@ const MaterialsList = ({ apiBaseUrl }) => {
{showNewMaterialForm ? 'Annuller' : '+ Nyt Materiale'}
</button>
<button
onClick={loadMaterials}
onClick={() => loadMaterials(currentPage, searchTerm, selectedCategory)}
className="refresh-btn"
disabled={loading}
>
@@ -369,17 +395,72 @@ const MaterialsList = ({ apiBaseUrl }) => {
<div className="summary-stats">
<div className="stat-item">
<span className="stat-label">Totalt materialer</span>
<span className="stat-value">{totalItems}</span>
</div>
<div className="stat-item">
<span className="stat-label">Viste resultater</span>
<span className="stat-value">{materials.length}</span>
</div>
<div className="stat-item">
<span className="stat-label">Filtrerede resultater</span>
<span className="stat-value">{filteredAndSortedMaterials.length}</span>
<span className="stat-label">Side</span>
<span className="stat-value">{currentPage} af {totalPages}</span>
</div>
<div className="stat-item">
<span className="stat-label">Kategorier</span>
<span className="stat-value">{categories.length}</span>
</div>
</div>
{/* Pagination Controls */}
{totalPages > 1 && (
<div className="pagination-controls">
<button
onClick={() => loadMaterials(1, searchTerm, selectedCategory)}
disabled={currentPage === 1 || loading}
className="page-btn"
>
« Første
</button>
<button
onClick={() => loadMaterials(currentPage - 1, searchTerm, selectedCategory)}
disabled={currentPage === 1 || loading}
className="page-btn"
>
Forrige
</button>
<span className="page-info">
Side {currentPage} af {totalPages}
</span>
<button
onClick={() => loadMaterials(currentPage + 1, searchTerm, selectedCategory)}
disabled={currentPage === totalPages || loading}
className="page-btn"
>
Næste
</button>
<button
onClick={() => loadMaterials(totalPages, searchTerm, selectedCategory)}
disabled={currentPage === totalPages || loading}
className="page-btn"
>
Sidste »
</button>
<select
value={itemsPerPage}
onChange={(e) => {
setItemsPerPage(parseInt(e.target.value));
setCurrentPage(1);
}}
className="items-per-page"
>
<option value={25}>25 per side</option>
<option value={50}>50 per side</option>
<option value={100}>100 per side</option>
<option value={200}>200 per side</option>
</select>
</div>
)}
</div>
{loading ? (
@@ -388,7 +469,7 @@ const MaterialsList = ({ apiBaseUrl }) => {
</div>
) : (
<div>
{filteredAndSortedMaterials.length === 0 ? (
{sortedMaterials.length === 0 ? (
<div className="empty-state">
<p>📭 Ingen materialer fundet</p>
{(searchTerm || selectedCategory) && (
@@ -410,7 +491,7 @@ const MaterialsList = ({ apiBaseUrl }) => {
</tr>
</thead>
<tbody>
{filteredAndSortedMaterials.map((material) => (
{sortedMaterials.map((material) => (
<tr key={material.id} className="material-row">
{editingMaterial === material.id ? (
// Edit mode
@@ -488,15 +569,23 @@ const MaterialsList = ({ apiBaseUrl }) => {
) : (
// View mode
<>
<td className="material-name">{material.name}</td>
<td className="material-name">
{material.name || material.product_name}
</td>
<td>
<span className="category-badge">{material.category || '-'}</span>
<span className="category-badge">
{material.category || material.subcategory || '-'}
</span>
</td>
<td className="unit-cell">
{material.unit || material.sales_unit || '-'}
</td>
<td className="unit-cell">{material.unit}</td>
<td className="price-cell">
{formatCurrency(material.unit_price)}
{formatCurrency(material.unit_price || material.price)}
</td>
<td className="supplier-cell">
{material.supplier_name || (material.source === 'bygma' ? 'Bygma' : '-')}
</td>
<td className="supplier-cell">{material.supplier_name || '-'}</td>
<td className="description-cell" title={material.description}>
{material.description ?
(material.description.length > 40 ?
@@ -506,20 +595,29 @@ const MaterialsList = ({ apiBaseUrl }) => {
}
</td>
<td className="actions">
<button
onClick={() => startEditMaterial(material)}
className="edit-btn"
title="Rediger materiale"
>
</button>
<button
onClick={() => deleteMaterial(material.id, material.name)}
className="delete-btn"
title="Slet materiale"
>
🗑
</button>
{material.source !== 'bygma' && (
<>
<button
onClick={() => startEditMaterial(material)}
className="edit-btn"
title="Rediger materiale"
>
</button>
<button
onClick={() => deleteMaterial(material.id, material.name)}
className="delete-btn"
title="Slet materiale"
>
🗑
</button>
</>
)}
{material.source === 'bygma' && (
<span className="bygma-badge" title="Bygma produkt - kan ikke redigeres">
🏗 Bygma
</span>
)}
</td>
</>
)}
+117 -22
View File
@@ -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