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)} > + + + + ))} 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.