feat: Add pagination and Bygma product handling to materials API and UI
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user