diff --git a/backend/src/routes/smartPackagesRoutes.js b/backend/src/routes/smartPackagesRoutes.js
index 2353a39..2ef5a67 100644
--- a/backend/src/routes/smartPackagesRoutes.js
+++ b/backend/src/routes/smartPackagesRoutes.js
@@ -100,6 +100,8 @@ const getCompletedMapping = (mappingJobId, fileHash) => {
return job.result;
};
+const stripStoredExcelPrefix = filename => filename.replace(/^\d+-/, '');
+
// ========================================
// SMART PAKKER API ENDPOINTS
// ========================================
@@ -286,6 +288,48 @@ router.get('/material-master-search', async (req, res) => {
});
// Upload Excel-fil til senere SmartPakker-import
+router.get('/excel-uploads', verifyToken, async (req, res) => {
+ try {
+ fs.mkdirSync(excelUploadDir, { recursive: true });
+ const uploadedFiles = fs.readdirSync(excelUploadDir)
+ .filter(filename => ['.xlsx', '.xls', '.xlsm', '.csv'].includes(path.extname(filename).toLowerCase()))
+ .map(filename => {
+ const stat = fs.statSync(path.join(excelUploadDir, filename));
+ return {
+ filename,
+ displayName: stripStoredExcelPrefix(filename),
+ size: stat.size,
+ uploadedAt: stat.mtime.toISOString()
+ };
+ });
+ const importRows = await global.databaseService.query(
+ `SELECT excel_source_file,
+ SUM(is_active = 1) AS active_packages,
+ SUM(is_active = 0) AS inactive_packages,
+ MAX(updated_at) AS imported_at
+ FROM material_packages
+ WHERE created_by = 'excel-import' AND excel_source_file IS NOT NULL
+ GROUP BY excel_source_file`
+ );
+ const importsByFile = new Map(importRows.map(row => [row.excel_source_file, row]));
+ const files = uploadedFiles.map(file => {
+ const importInfo = importsByFile.get(file.filename);
+ return {
+ ...file,
+ importStatus: importInfo ? 'imported' : 'uploaded',
+ activePackages: Number(importInfo?.active_packages || 0),
+ inactivePackages: Number(importInfo?.inactive_packages || 0),
+ importedAt: importInfo?.imported_at || null
+ };
+ }).sort((left, right) => new Date(right.uploadedAt) - new Date(left.uploadedAt));
+
+ return res.json({ success: true, files: files.slice(0, 20) });
+ } catch (error) {
+ console.error('Error listing SmartPakker Excel uploads:', error);
+ return res.status(500).json({ success: false, error: 'Kunne ikke hente Excel-historikken' });
+ }
+});
+
router.post('/excel-upload', verifyToken, (req, res) => {
excelUpload.single('file')(req, res, async (error) => {
if (error) {
diff --git a/frontend/src/components/FinalReview.js b/frontend/src/components/FinalReview.js
index 37291bf..ee7655a 100644
--- a/frontend/src/components/FinalReview.js
+++ b/frontend/src/components/FinalReview.js
@@ -6,6 +6,7 @@ import { PROJECT_STATUS, getProjectStatusMeta, isFinalizedProjectStatus } from '
import { FLOW_STEPS, getActiveStageIndex } from '../utils/statusFlow';
import AiControlCenter from './ai/AiControlCenter';
import { markQuoteFlowMilestone } from '../utils/quoteFlowTelemetry';
+import { normalizeProjectLine } from '../utils/projectLines';
const toMoneyNumber = (value) => {
const parsed = Number(value);
@@ -295,10 +296,10 @@ const FinalReview = ({
const [submitResult, setSubmitResult] = useState(null);
// Editable pricing state
- const initialMaterials = (packageData?.materials || []).filter((item) => !isRentalItem(item));
+ const initialMaterials = (packageData?.materials || []).filter((item) => !isRentalItem(item)).map(normalizeProjectLine);
const initialRentals = (packageData?.rentals || []).length > 0
- ? packageData.rentals
- : (packageData?.materials || []).filter((item) => isRentalItem(item));
+ ? packageData.rentals.map(normalizeProjectLine)
+ : (packageData?.materials || []).filter((item) => isRentalItem(item)).map(normalizeProjectLine);
const [editableMaterials, setEditableMaterials] = useState(initialMaterials);
const [editableRentalItems, setEditableRentalItems] = useState(initialRentals);
const [editableLaborTasks, setEditableLaborTasks] = useState(packageData?.laborTasks || []);
@@ -545,11 +546,11 @@ const FinalReview = ({
// Sync editable state when packageData changes
React.useEffect(() => {
if (packageData?.materials) {
- setEditableMaterials(packageData.materials.filter((item) => !isRentalItem(item)));
+ setEditableMaterials(packageData.materials.filter((item) => !isRentalItem(item)).map(normalizeProjectLine));
setEditableRentalItems(
(packageData?.rentals || []).length > 0
- ? packageData.rentals
- : packageData.materials.filter((item) => isRentalItem(item))
+ ? packageData.rentals.map(normalizeProjectLine)
+ : packageData.materials.filter((item) => isRentalItem(item)).map(normalizeProjectLine)
);
}
if (packageData?.laborTasks) {
diff --git a/frontend/src/components/ProjectFlow.js b/frontend/src/components/ProjectFlow.js
index 65744f9..705daef 100644
--- a/frontend/src/components/ProjectFlow.js
+++ b/frontend/src/components/ProjectFlow.js
@@ -7,6 +7,7 @@ import FinalReview from './FinalReview';
import { useNotification } from '../hooks/useNotification';
import { getProjectStatusMeta, getProjectStepFromStatus, isFinalizedProjectStatus } from '../utils/projectStatus';
import { normalizeProjectLaborResponse } from '../utils/projectLabor';
+import { normalizeProjectLine, reconcilePackageLines } from '../utils/projectLines';
import {
markQuoteFlowMilestone,
setProductContext,
@@ -382,13 +383,14 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
const materialsData = await materialsResponse.json();
if (materialsData.success && materialsData.materials) {
console.log('🧱 Loaded materials:', materialsData.materials.length, 'items');
- loadedMaterials = materialsData.materials.map((material) => ({
+ loadedMaterials = materialsData.materials.map((material) => normalizeProjectLine({
id: material.id,
name: material.material_name || material.name,
category: material.material_category || material.category || 'Øvrige',
quantity: parseFloat(material.quantity) || 0,
unit: material.unit || 'stk',
- unitPrice: parseFloat(material.unit_price ?? material.unitPrice ?? 0) || 0,
+ unitPrice: material.unit_price ?? material.unitPrice,
+ total: material.total_price ?? material.total,
supplier: material.supplier || null,
calculation: material.notes || ''
}));
@@ -405,7 +407,7 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
if (rentalsResponse.ok) {
const rentalsData = await rentalsResponse.json();
if (rentalsData.success && rentalsData.rentals) {
- loadedRentals = rentalsData.rentals.map((r) => ({
+ loadedRentals = rentalsData.rentals.map((r) => normalizeProjectLine({
name: r.rental_name || r.name,
category: r.rental_category || r.category,
quantity: r.quantity,
@@ -447,7 +449,11 @@ const ProjectFlow = ({ apiBaseUrl, onNavigateToCompletedQuotes }) => {
sessionPackageData.rentals = loadedRentals;
}
const restoredPackageData = {
- ...sessionPackageData,
+ ...reconcilePackageLines({
+ sessionPackageData,
+ persistedMaterials: loadedMaterials,
+ persistedRentals: loadedRentals
+ }),
laborTasks: Array.isArray(sessionPackageData.laborTasks) && sessionPackageData.laborTasks.length > 0
? sessionPackageData.laborTasks
: loadedLaborTasks
diff --git a/frontend/src/components/smartPackages/SmartPackages.js b/frontend/src/components/smartPackages/SmartPackages.js
index f3ba229..cea611c 100644
--- a/frontend/src/components/smartPackages/SmartPackages.js
+++ b/frontend/src/components/smartPackages/SmartPackages.js
@@ -60,6 +60,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
const [excelMappingJob, setExcelMappingJob] = useState(null);
const [excelMapping, setExcelMapping] = useState(null);
const [showAllExcelIssues, setShowAllExcelIssues] = useState(false);
+ const [excelUploads, setExcelUploads] = useState([]);
// Filtre og søgning
const [filters, setFilters] = useState({
@@ -103,8 +104,18 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
console.log('🔵 SmartPackages: useEffect triggered, fetching packages...');
fetchPackages();
fetchCategories();
+ fetchExcelUploads();
// eslint-disable-next-line react-hooks/exhaustive-deps -- fetchPackages/fetchCategories are stable functions
}, [currentPage, filters]);
+
+ const fetchExcelUploads = async () => {
+ try {
+ const response = await axios.get('/api/smart-packages/excel-uploads');
+ setExcelUploads(response.data?.files || []);
+ } catch (error) {
+ console.warn('Kunne ikke hente Excel-historik:', error);
+ }
+ };
// Hent pakker fra API
const fetchPackages = async () => {
@@ -268,6 +279,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
if (response.data.success) {
setUploadedExcelFile(response.data.file);
+ await fetchExcelUploads();
setExcelValidation({ standard: response.data.standardValidation, ai: null });
if (response.data.standardValidation?.blocksImport) {
@@ -412,6 +424,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
setExcelImportResult(response.data.result);
setExcelPhase('imported');
+ await fetchExcelUploads();
setCurrentPage(1);
await Promise.all([fetchPackages(), fetchCategories()]);
enqueueSnackbar(`${response.data.result.totalPackages} SmartPakker importeret`, { variant: 'success' });
@@ -739,6 +752,46 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
{' '}{excelImportResult.updatedPackages} opdateret og {excelImportResult.deactivatedPackages} deaktiveret.
)}
+
+ {excelUploads.length > 0 && (
+
+
+ Seneste Excel-filer
+
+ {excelUploads.slice(0, 5).map(file => (
+
+
+
+ {file.displayName}
+
+
+ {new Date(file.uploadedAt).toLocaleString('da-DK')} · {(file.size / 1024).toFixed(0)} KB
+
+
+
+
+ ))}
+
+
+ )}
@@ -890,9 +943,9 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
>
{
border: '1px solid rgba(148, 163, 184, 0.35)'
}}
/>
-
+
{packageItem.name}
@@ -918,7 +971,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
-
+
{
-
-
-
+
+
{
-
+
-
+
handleViewPackage(packageItem)}
>
@@ -985,6 +1047,7 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
handleEditPackage(packageItem.id)}
>
@@ -995,12 +1058,26 @@ const SmartPackages = ({ onEdit, selectedCategory = null }) => {
confirmDeletePackage(packageItem)}
>
+
+
+ }
+ onClick={() => handleViewPackage(packageItem)}
+ >
+ Se detaljer
+
+
))}
diff --git a/frontend/src/utils/projectLines.js b/frontend/src/utils/projectLines.js
new file mode 100644
index 0000000..de75a24
--- /dev/null
+++ b/frontend/src/utils/projectLines.js
@@ -0,0 +1,30 @@
+export const normalizeProjectLine = (line = {}) => {
+ const quantity = Number.parseFloat(line.quantity) || 0;
+ const explicitUnitPrice = Number.parseFloat(line.unitPrice ?? line.unit_price);
+ const explicitTotal = Number.parseFloat(
+ line.total ?? line.totalPrice ?? line.total_price ?? line.line_total
+ );
+ const unitPrice = Number.isFinite(explicitUnitPrice)
+ ? explicitUnitPrice
+ : (quantity > 0 && Number.isFinite(explicitTotal) ? explicitTotal / quantity : 0);
+
+ return {
+ ...line,
+ name: line.name || line.material_name || line.rental_name || 'Ukendt linje',
+ category: line.category || line.material_category || line.rental_category || 'Øvrige',
+ quantity,
+ unit: line.unit || 'stk',
+ unitPrice,
+ total: Number.isFinite(explicitTotal) ? explicitTotal : quantity * unitPrice
+ };
+};
+
+export const reconcilePackageLines = ({ sessionPackageData, persistedMaterials, persistedRentals }) => ({
+ ...sessionPackageData,
+ materials: persistedMaterials.length > 0
+ ? persistedMaterials.map(normalizeProjectLine)
+ : (sessionPackageData.materials || []).map(normalizeProjectLine),
+ rentals: persistedRentals.length > 0
+ ? persistedRentals.map(normalizeProjectLine)
+ : (sessionPackageData.rentals || []).map(normalizeProjectLine)
+});
diff --git a/frontend/src/utils/projectLines.test.js b/frontend/src/utils/projectLines.test.js
new file mode 100644
index 0000000..8ee069c
--- /dev/null
+++ b/frontend/src/utils/projectLines.test.js
@@ -0,0 +1,32 @@
+import { normalizeProjectLine, reconcilePackageLines } from './projectLines';
+
+describe('project line normalization', () => {
+ test('maps persisted snake_case prices into the editable line contract', () => {
+ expect(normalizeProjectLine({
+ material_name: 'Betontagsten',
+ quantity: '72.700',
+ unit: 'm²',
+ unit_price: '180.00',
+ total_price: '13086.00'
+ })).toMatchObject({
+ name: 'Betontagsten',
+ quantity: 72.7,
+ unitPrice: 180,
+ total: 13086
+ });
+ });
+
+ test('lets persisted project lines replace stale zero-price session lines', () => {
+ const result = reconcilePackageLines({
+ sessionPackageData: {
+ materials: [{ name: 'Betontagsten', quantity: 72.7, unitPrice: 0 }],
+ rentals: [{ name: 'Stillads', quantity: 1, unitPrice: 0 }]
+ },
+ persistedMaterials: [{ material_name: 'Betontagsten', quantity: 72.7, unit_price: 180 }],
+ persistedRentals: [{ rental_name: 'Stillads', quantity: 1, unit_price: 14000 }]
+ });
+
+ expect(result.materials[0].unitPrice).toBe(180);
+ expect(result.rentals[0].unitPrice).toBe(14000);
+ });
+});
diff --git a/status.md b/status.md
index 1c0e23a..86406bc 100644
--- a/status.md
+++ b/status.md
@@ -236,6 +236,16 @@ Formål: Genbrug godkendte data sikkert videre i driften og bevar et revisionssp
8. Tag højst ét større initiativ i gang pr. fase ad gangen, løs og verificér det, og opdatér denne tabel ved hver ændring.
9. Ved hver månedskontrol revurderes prioritet, ejer, måldato, status og beståelseskriterium.
+## GitHub-fejlrettelser, 13. august 2026
+
+| Issue | Resultat | Verifikation |
+|---|---|---|
+| #9 — Manglende smartpakker/Excel-fil | ✅ Løst | Smartpakker viser nu de seneste Excel-uploads efter genindlæsning samt importstatus. Den importerede fil er synlig med **414 aktive pakker**. |
+| #10 — For meget indhold i feltet | ✅ Løst | Smartpakkekort er komprimeret på 390 × 844 px: kategori, navn, tid/pris og én tydelig “Se detaljer”-knap vises; gentaget pris, metadata og skrivebordshandlinger er skjult på mobil. |
+| #11 — Materiale- og udlejningslinjer viser 0,00 kr. | ✅ Løst | Projekt 392 viser i Final Review 11 prissatte materialelinjer, **44.981,80 kr.** i materialer og **14.000,00 kr.** i udlejning. Persistente projektlinjer tilsidesætter nu forældede sessionslinjer. |
+
+Teknisk regression: backend **38 suites / 195 tests**, frontend **8 suites / 19 tests**, production build og live Playwright-kontrol bestod. PM2-servicen blev genstartet, og `/api/health` svarede grønt.
+
## Anbefalet næste handling
**Platform:** Gennemfør næste tømrer-mode-feltforsøg fra helt ny kunde gennem fysisk besigtigelse på mobil via den nye “Nyt tilbud”-indgang. August-rapportens kendte-projekt-baseline og den første mobile UX-bølge er oprettet; næste kontrol skal især måle resterende manuel indtastning, offline-risiko og den åbne P2-checkliste.