diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9a546a9..b582e8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,11 +217,21 @@ jobs: sleep 10 curl -f http://localhost:4032/api/health || exit 1 - - name: Run Playwright tests + - name: Run blocking carpenter smoke test working-directory: tests env: PLAYWRIGHT_BASE_URL: http://localhost:4032 - run: npx playwright test --project=chromium --reporter=html + PLAYWRIGHT_USERNAME: ci-test-user + PLAYWRIGHT_PASSWORD: ci-test-password + run: npx playwright test full-roof-quote-flow.spec.js --project=chromium --reporter=list + + - name: Run extended Playwright tests + working-directory: tests + env: + PLAYWRIGHT_BASE_URL: http://localhost:4032 + PLAYWRIGHT_USERNAME: ci-test-user + PLAYWRIGHT_PASSWORD: ci-test-password + run: npx playwright test --project=chromium --reporter=html --grep-invert "full carpenter roof quote smoke flow" continue-on-error: true - name: Upload Playwright report @@ -280,14 +290,15 @@ jobs: ci-summary: name: CI Summary runs-on: ubuntu-latest - needs: [lint, backend-tests, frontend-build, security-scan] + needs: [lint, backend-tests, frontend-build, security-scan, e2e-tests] if: always() steps: - name: Check CI status run: | if [[ "${{ needs.lint.result }}" == "failure" ]] || \ [[ "${{ needs.backend-tests.result }}" == "failure" ]] || \ - [[ "${{ needs.frontend-build.result }}" == "failure" ]]; then + [[ "${{ needs.frontend-build.result }}" == "failure" ]] || \ + [[ "${{ needs.e2e-tests.result }}" == "failure" ]]; then echo "CI failed!" exit 1 fi diff --git a/backend/__tests__/customerProjectsMaterials.test.js b/backend/__tests__/customerProjectsMaterials.test.js index 5094f11..f3c50d3 100644 --- a/backend/__tests__/customerProjectsMaterials.test.js +++ b/backend/__tests__/customerProjectsMaterials.test.js @@ -106,4 +106,55 @@ describe('customer project material creation routes', () => { code: 'MATERIAL_NAME_TOO_LONG' }); }); + + test('returns read-only material match suggestions for a project', async () => { + const suggestions = [{ + projectMaterialId: 501, + status: 'matched_name', + score: 0.91, + candidate: { id: 10, sku: 'TAG-001', name: 'Taglægte 38x73 mm C24' } + }]; + const preview = jest.spyOn(ProjectMaterialService.prototype, 'previewMaterialMatches') + .mockResolvedValue(suggestions); + + const res = await request(buildApp()) + .post('/api/customer-projects/projects/392/materials/match-preview') + .send({ materialIds: [501] }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, matches: suggestions }); + expect(preview).toHaveBeenCalledWith(392, [501]); + }); + + test('links only an explicitly confirmed project material candidate', async () => { + const linked = { + projectMaterialId: 501, + materialId: 10, + name: 'Taglægte 38x73 mm C24', + unitPrice: 14.5, + totalPrice: 29 + }; + const link = jest.spyOn(ProjectMaterialService.prototype, 'linkProjectMaterial') + .mockResolvedValue(linked); + + const res = await request(buildApp()) + .put('/api/customer-projects/projects/392/materials/501/link') + .send({ materialId: 10 }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ success: true, material: linked }); + expect(link).toHaveBeenCalledWith(392, 501, 10); + }); + + test('rejects a missing master material id before linking', async () => { + const link = jest.spyOn(ProjectMaterialService.prototype, 'linkProjectMaterial'); + + const res = await request(buildApp()) + .put('/api/customer-projects/projects/392/materials/501/link') + .send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ success: false, error: 'Materiale-id er påkrævet' }); + expect(link).not.toHaveBeenCalled(); + }); }); diff --git a/backend/src/__tests__/productTelemetryService.test.js b/backend/src/__tests__/productTelemetryService.test.js index 17dab2d..fc20228 100644 --- a/backend/src/__tests__/productTelemetryService.test.js +++ b/backend/src/__tests__/productTelemetryService.test.js @@ -33,6 +33,29 @@ describe('ProductTelemetryService', () => { })); }); + test('accepts sanitized project hydration events', () => { + const service = new ProductTelemetryService({ pool: { execute: jest.fn() } }); + + expect(service.normalizeEvent({ + sessionId: 'flow-123', + eventType: 'project_hydration_started', + projectId: 392 + })).toMatchObject({ eventType: 'project_hydration_started', metadata: {} }); + expect(service.normalizeEvent({ + sessionId: 'flow-123', + eventType: 'project_hydration_ready', + projectId: 392, + metadata: { + durationMs: 2480, + slowestResource: 'packages', + customerName: 'må ikke gemmes' + } + })).toMatchObject({ + eventType: 'project_hydration_ready', + metadata: { durationMs: 2480, slowestResource: 'packages' } + }); + }); + test('rejects unknown event types', () => { const service = new ProductTelemetryService({ pool: { execute: jest.fn() } }); expect(() => service.normalizeEvent({ diff --git a/backend/src/__tests__/projectMaterialService.test.js b/backend/src/__tests__/projectMaterialService.test.js index e891b8f..7d79dbe 100644 --- a/backend/src/__tests__/projectMaterialService.test.js +++ b/backend/src/__tests__/projectMaterialService.test.js @@ -95,4 +95,112 @@ describe('ProjectMaterialService price provenance', () => { expect(execute).not.toHaveBeenCalled(); }); + + test('previews safe master-material matches without writing', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('FROM project_materials')) { + return [[{ + id: 501, + project_id: 392, + material_name: 'Taglægte 38x73 mm', + material_category: 'Træ', + quantity: 59.6, + unit: 'lbm', + unit_price: 18 + }]]; + } + if (sql.includes('FROM materials m')) { + return [[{ + id: 10, + sku: 'TAG-001', + name: 'Taglægte 38x73 mm C24', + description: '', + unit: 'lbm', + category: 'Træ', + price: 14.5, + supplier_name: 'Stark A/S' + }]]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }); + const service = new ProjectMaterialService({ pool: { execute } }); + + const result = await service.previewMaterialMatches(392, [501]); + + expect(result).toEqual([expect.objectContaining({ + projectMaterialId: 501, + status: 'matched_name', + candidate: expect.objectContaining({ id: 10, sku: 'TAG-001', price: 14.5 }) + })]); + expect(execute).toHaveBeenCalledTimes(2); + expect(execute.mock.calls.some(([sql]) => /^\s*(UPDATE|INSERT|DELETE)/i.test(sql))).toBe(false); + }); + + test('links a confirmed master material only within the requested project', async () => { + const execute = jest.fn(async (sql, params) => { + if (sql.includes('FROM project_materials')) { + expect(params).toEqual([501, 392]); + return [[{ + id: 501, + project_id: 392, + material_name: 'Taglægte 38x73 mm', + quantity: 2, + unit: 'lbm' + }]]; + } + if (sql.includes('FROM materials m')) { + return [[{ + id: 10, + sku: 'TAG-001', + name: 'Taglægte 38x73 mm C24', + unit: 'lbm', + category: 'Træ', + price: 14.5, + supplier_name: 'Stark A/S' + }]]; + } + if (sql.includes('UPDATE project_materials')) { + expect(params).toEqual([ + 10, + 'Taglægte 38x73 mm C24', + 'Træ', + 'lbm', + 14.5, + 29, + 'Stark A/S', + 'Stark A/S', + 501, + 392 + ]); + return [{ affectedRows: 1 }]; + } + throw new Error(`Unexpected SQL: ${sql}`); + }); + const service = new ProjectMaterialService({ pool: { execute } }); + + const linked = await service.linkProjectMaterial(392, 501, 10); + + expect(linked).toMatchObject({ + projectMaterialId: 501, + materialId: 10, + name: 'Taglægte 38x73 mm C24', + unitPrice: 14.5, + totalPrice: 29 + }); + }); + + test('rejects linking to a missing or unpriced master material without writing', async () => { + const execute = jest.fn(async sql => { + if (sql.includes('FROM project_materials')) return [[{ id: 501, project_id: 392, quantity: 2 }]]; + if (sql.includes('FROM materials m')) return [[]]; + throw new Error(`Unexpected SQL: ${sql}`); + }); + const service = new ProjectMaterialService({ pool: { execute } }); + + await expect(service.linkProjectMaterial(392, 501, 999)).rejects.toMatchObject({ + status: 400, + code: 'INVALID_MASTER_MATERIAL' + }); + expect(execute.mock.calls.some(([sql]) => sql.includes('UPDATE project_materials'))).toBe(false); + }); }); diff --git a/backend/src/routes/customerProjects.js b/backend/src/routes/customerProjects.js index 810bfa4..1e7257b 100644 --- a/backend/src/routes/customerProjects.js +++ b/backend/src/routes/customerProjects.js @@ -661,6 +661,53 @@ router.post('/projects/:id/materials/bulk', async (req, res) => { } }); +// Foreslå masterdatabase-koblinger uden at skrive ændringer +router.post('/projects/:id/materials/match-preview', async (req, res) => { + try { + const projectId = Number(req.params.id); + const materialIds = req.body?.materialIds ?? []; + if (!Number.isInteger(projectId) || projectId <= 0 || !Array.isArray(materialIds) || materialIds.length > 100) { + return res.status(400).json({ success: false, error: 'Ugyldige match-data' }); + } + const normalizedIds = materialIds.map(Number); + if (normalizedIds.some(id => !Number.isInteger(id) || id <= 0)) { + return res.status(400).json({ success: false, error: 'Ugyldige materiale-id\'er' }); + } + const matches = await projectMaterialService.previewMaterialMatches(projectId, normalizedIds); + return res.json({ success: true, matches }); + } catch (error) { + logger.error('Error previewing project material matches:', error); + return res.status(500).json({ success: false, error: 'Kunne ikke foreslå materialekoblinger' }); + } +}); + +// Gem kun en kobling, som brugeren eksplicit har bekræftet +router.put('/projects/:id/materials/:projectMaterialId/link', async (req, res) => { + try { + const projectId = Number(req.params.id); + const projectMaterialId = Number(req.params.projectMaterialId); + const materialId = Number(req.body?.materialId); + if (![projectId, projectMaterialId, materialId].every(value => Number.isInteger(value) && value > 0)) { + return res.status(400).json({ success: false, error: 'Materiale-id er påkrævet' }); + } + const material = await projectMaterialService.linkProjectMaterial( + projectId, + projectMaterialId, + materialId + ); + return res.json({ success: true, material }); + } catch (error) { + logger.error('Error linking project material:', error); + const publicErrors = new Set(['PROJECT_MATERIAL_NOT_FOUND', 'INVALID_MASTER_MATERIAL']); + const isPublicError = publicErrors.has(error.code); + return res.status(isPublicError ? error.status : 500).json({ + success: false, + error: isPublicError ? error.message : 'Kunne ikke gemme materialekoblingen', + ...(isPublicError ? { code: error.code } : {}) + }); + } +}); + // Søg i eksisterende materiale database router.get('/projects/:id/materials/search', async (req, res) => { try { diff --git a/backend/src/services/productTelemetryService.js b/backend/src/services/productTelemetryService.js index a64a6ea..d34748c 100644 --- a/backend/src/services/productTelemetryService.js +++ b/backend/src/services/productTelemetryService.js @@ -17,7 +17,9 @@ const ALLOWED_EVENT_TYPES = new Set([ 'app_loaded', 'flow_reopened', 'manual_correction', - 'pdf_failed' + 'pdf_failed', + 'project_hydration_started', + 'project_hydration_ready' ]); const SAFE_METADATA_KEYS = new Set([ @@ -25,7 +27,9 @@ const SAFE_METADATA_KEYS = new Set([ 'viewportWidth', 'viewportHeight', 'connectionType', - 'projectType' + 'projectType', + 'durationMs', + 'slowestResource' ]); const toFiniteNumber = (value, fallback = 0) => { diff --git a/backend/src/services/projectMaterialService.js b/backend/src/services/projectMaterialService.js index 84c8f69..3dfdf02 100644 --- a/backend/src/services/projectMaterialService.js +++ b/backend/src/services/projectMaterialService.js @@ -1,4 +1,8 @@ const logger = require('../utils/logger'); +const { + findBestMaterialMatch, + loadMaterialCatalog +} = require('./smartPackageMaterialMatchService'); const toMysqlDateTime = value => { if (!value) return null; @@ -437,6 +441,114 @@ class ProjectMaterialService { } } + async previewMaterialMatches(projectId, projectMaterialIds = []) { + const ids = [...new Set((projectMaterialIds || []) + .map(Number) + .filter(Number.isInteger) + .filter(id => id > 0))]; + const params = [projectId]; + let filter = ''; + if (ids.length > 0) { + filter = ` AND id IN (${ids.map(() => '?').join(', ')})`; + params.push(...ids); + } + + const [projectMaterials] = await this.db.pool.execute(` + SELECT id, project_id, material_id, material_name, material_category, + quantity, unit, unit_price, supplier + FROM project_materials + WHERE project_id = ?${filter} + ORDER BY id + `, params); + const catalog = await loadMaterialCatalog(this.db.pool); + + return projectMaterials.map(line => { + const match = findBestMaterialMatch({ name: line.material_name, unit: line.unit }, catalog); + const candidate = match.material || match.suggestedMaterial || null; + return { + projectMaterialId: line.id, + currentMaterialId: line.material_id || null, + name: line.material_name, + unit: line.unit, + status: match.status, + score: match.score, + candidate: candidate ? { + id: candidate.id, + sku: candidate.sku, + name: candidate.name, + unit: candidate.unit, + category: candidate.category, + price: candidate.price, + supplier: candidate.supplier_name || null + } : null + }; + }); + } + + async linkProjectMaterial(projectId, projectMaterialId, masterMaterialId) { + const [projectRows] = await this.db.pool.execute(` + SELECT id, project_id, material_name, quantity, unit + FROM project_materials + WHERE id = ? AND project_id = ? + `, [projectMaterialId, projectId]); + if (projectRows.length === 0) { + const error = new Error('Project material not found'); + error.status = 404; + error.code = 'PROJECT_MATERIAL_NOT_FOUND'; + throw error; + } + + const catalog = await loadMaterialCatalog(this.db.pool); + const masterMaterial = catalog.find(material => Number(material.id) === Number(masterMaterialId)); + if (!masterMaterial || !(Number(masterMaterial.price) > 0)) { + const error = new Error('Master material is missing or has no active price'); + error.status = 400; + error.code = 'INVALID_MASTER_MATERIAL'; + throw error; + } + + const quantity = Number(projectRows[0].quantity) || 0; + const unitPrice = Number(masterMaterial.price); + const totalPrice = quantity * unitPrice; + const supplier = masterMaterial.supplier_name || null; + const [result] = await this.db.pool.execute(` + UPDATE project_materials + SET material_id = ?, material_name = ?, material_category = ?, unit = ?, + unit_price = ?, total_price = ?, supplier = ?, price_source = ?, + price_source_updated_at = NOW() + WHERE id = ? AND project_id = ? + `, [ + masterMaterial.id, + masterMaterial.name, + masterMaterial.category || null, + masterMaterial.unit || projectRows[0].unit, + unitPrice, + totalPrice, + supplier, + supplier, + projectMaterialId, + projectId + ]); + if (result.affectedRows === 0) { + const error = new Error('Project material not found'); + error.status = 404; + error.code = 'PROJECT_MATERIAL_NOT_FOUND'; + throw error; + } + + return { + projectMaterialId, + materialId: masterMaterial.id, + sku: masterMaterial.sku, + name: masterMaterial.name, + category: masterMaterial.category || null, + unit: masterMaterial.unit || projectRows[0].unit, + unitPrice, + totalPrice, + supplier + }; + } + // Hent material kategorier fra database async getMaterialCategories() { try { diff --git a/docs/qa/CARPENTER_WALKTHROUGH_RELEASE_GATE.md b/docs/qa/CARPENTER_WALKTHROUGH_RELEASE_GATE.md new file mode 100644 index 0000000..3e07c32 --- /dev/null +++ b/docs/qa/CARPENTER_WALKTHROUGH_RELEASE_GATE.md @@ -0,0 +1,43 @@ +# Carpenter Walkthrough Release Gate + +## Blocking CI gate + +Every pull request to `main` runs `tests/full-roof-quote-flow.spec.js` as a blocking Playwright smoke test against the CI server. It verifies app login, supported roof types, geometry calculation and the core pricing basis. + +The remaining Playwright suite is still best-effort while legacy scenarios are stabilized. Artifact upload is always best-effort and must not decide CI status. + +## Monthly production audit + +Run `tests/monthly-carpenter-audit.spec.js` against the production-like target with a known audit project: + +```bash +cd tests +PLAYWRIGHT_BASE_URL=https://tilbudsgiveren.alw.dk \ +PLAYWRIGHT_USERNAME="$PLAYWRIGHT_USERNAME" \ +PLAYWRIGHT_PASSWORD="$PLAYWRIGHT_PASSWORD" \ +AUDIT_PROJECT_ID=392 \ +CARPENTER_HYDRATION_SLA_MS=5000 \ +npx playwright test monthly-carpenter-audit.spec.js --project=chromium --reporter=list +``` + +The audit fails when: + +- project hydration exceeds the explicit SLA +- ProjectFlow and Final Review disagree about unlinked material count +- controlled quote total changes unexpectedly +- a newly generated PDF is stale, invalid or longer than three pages +- the browser observes API 5xx responses or console errors +- the mobile page has horizontal overflow + +Do not use the monthly audit as an automated production write from CI. Run it deliberately with the designated audit project after deploy and during the monthly carpenter review. + +## Release evidence + +Record: + +- commit and environment +- hydration duration +- PDF page count +- API/console failures +- readiness/material-link count +- screenshot/video paths when a gate fails diff --git a/frontend/src/components/FinalReview.css b/frontend/src/components/FinalReview.css index dfe8ad5..d96a6da 100644 --- a/frontend/src/components/FinalReview.css +++ b/frontend/src/components/FinalReview.css @@ -1280,3 +1280,38 @@ input[type="number"].editable-input:-webkit-autofill { white-space: normal; text-align: center; } + +.review-readiness-summary { + margin: 10px 0 0; + font-weight: 700; +} + +.material-link-review { + margin: 16px 0; + padding: 16px; + border: 1px solid #f59e0b; + border-radius: 10px; + background: #fffbeb; +} + +.material-link-review button { + min-height: 44px; + padding: 10px 14px; +} + +.material-link-review-list { + display: grid; + gap: 12px; + margin-top: 14px; +} + +.material-link-review-row { + padding: 12px; + border: 1px solid #fde68a; + border-radius: 8px; + background: #fff; +} + +.material-link-review-row p { + margin: 8px 0; +} diff --git a/frontend/src/components/FinalReview.js b/frontend/src/components/FinalReview.js index da58ce1..8bc46cf 100644 --- a/frontend/src/components/FinalReview.js +++ b/frontend/src/components/FinalReview.js @@ -5,8 +5,11 @@ import '../styles/statusFlow.css'; import { PROJECT_STATUS, getProjectStatusMeta, isFinalizedProjectStatus } from '../utils/projectStatus'; import { FLOW_STEPS, getActiveStageIndex } from '../utils/statusFlow'; import AiControlCenter from './ai/AiControlCenter'; +import MaterialLinkReview from './MaterialLinkReview'; import { markQuoteFlowMilestone } from '../utils/quoteFlowTelemetry'; import { normalizeProjectLine } from '../utils/projectLines'; +import { buildPdfSourceSignature } from '../utils/pdfSourceSignature'; +import { getProjectReadiness } from '../utils/projectReadiness'; const toMoneyNumber = (value) => { const parsed = Number(value); @@ -367,7 +370,7 @@ const FinalReview = ({ // PDF state const [isGeneratingPDF, setIsGeneratingPDF] = useState(false); const [pdfUrl, setPdfUrl] = useState(null); - const [isPdfStale, setIsPdfStale] = useState(false); + const [generatedPdfSignature, setGeneratedPdfSignature] = useState(null); const [pdfGeneratedAt, setPdfGeneratedAt] = useState(null); const [orderSuggestions, setOrderSuggestions] = useState(null); const [isLoadingOrderSuggestions, setIsLoadingOrderSuggestions] = useState(false); @@ -568,14 +571,6 @@ const FinalReview = ({ return () => clearTimeout(timer); }, [editableMaterials, editableRentalItems, editableLaborTasks]); - // Debug log for at se hvad vi får som props - console.log('🔍 FinalReview props debug:', { - project, - geometryData, - packageData, - apiBaseUrl - }); - // Normaliser geometri data til at håndtere begge navnekonventioner const normalizedGeometry = React.useMemo(() => (geometryData ? { width: geometryData.width || geometryData.width_main || 0, @@ -586,9 +581,6 @@ const FinalReview = ({ baseArea: geometryData.baseArea || geometryData.totalArea || geometryData.total_area || 0, roofCoveringArea: geometryData.roofCoveringArea || geometryData.roof_covering_area || geometryData.totalArea || geometryData.total_area || 0 } : null), [geometryData]); - - console.log('📊 Normalized geometry:', normalizedGeometry); - // Sync editable state when packageData changes React.useEffect(() => { if (packageData?.materials) { @@ -777,6 +769,24 @@ const FinalReview = ({ const subtotalWithProfit = toMoneyNumber(economics.totalExclVat); const taxAmount = toMoneyNumber(economics.vatAmount); const grandTotal = toMoneyNumber(economics.totalInclVat); + const currentPdfSignature = React.useMemo(() => buildPdfSourceSignature({ + quoteText, + projectDescription: editableProjectDescription, + geometry: normalizedGeometry, + materials: editableMaterials, + rentals: editableRentalItems, + laborTasks: editableLaborTasks, + economics + }), [ + quoteText, + editableProjectDescription, + normalizedGeometry, + editableMaterials, + editableRentalItems, + editableLaborTasks, + economics + ]); + const isPdfStale = Boolean(pdfUrl && generatedPdfSignature !== currentPdfSignature); React.useEffect(() => { if (!project?.id) return undefined; @@ -844,6 +854,50 @@ const FinalReview = ({ ]); const blockingReadinessChecks = quoteReadinessChecks.filter(check => check.severity === 'blocking'); const warningReadinessChecks = quoteReadinessChecks.filter(check => check.severity === 'warning'); + const sharedReadiness = React.useMemo(() => getProjectReadiness({ + project, + geometry: normalizedGeometry, + packageData, + materials: editableMaterials, + rentals: editableRentalItems, + laborTasks: editableLaborTasks, + projectDescription: editableProjectDescription, + orderSuggestions, + ordrestyringMetadata, + persistedCompletedSteps: 4 + }), [ + project, + normalizedGeometry, + packageData, + editableMaterials, + editableRentalItems, + editableLaborTasks, + editableProjectDescription, + orderSuggestions, + ordrestyringMetadata + ]); + const unlinkedEditableMaterials = React.useMemo(() => editableMaterials.filter(material => ( + !Number(material.materialId ?? material.material_id) + )), [editableMaterials]); + const handleMaterialLinked = React.useCallback(linked => { + setEditableMaterials(current => current.map(material => { + const projectMaterialId = Number(material.id ?? material.projectMaterialId); + if (projectMaterialId !== Number(linked.projectMaterialId)) return material; + return normalizeProjectLine({ + ...material, + id: linked.projectMaterialId, + materialId: linked.materialId, + name: linked.name, + category: linked.category, + unit: linked.unit, + unitPrice: linked.unitPrice, + total: linked.totalPrice, + supplier: linked.supplier, + priceSource: linked.supplier || material.priceSource, + priceSourceUpdatedAt: new Date().toISOString() + }); + })); + }, []); const hasCurrentProjectValidation = Boolean(projectValidation && !projectValidationStale); const isProjectValidationRunning = ['queued', 'running'].includes(projectValidationJob?.status); const isQuoteReadyForSubmit = blockingReadinessChecks.length === 0 @@ -1174,9 +1228,6 @@ const FinalReview = ({ if (result.success) { setQuoteText(result.quoteText); setShowQuoteEditor(true); - if (pdfUrl) { - setIsPdfStale(true); - } } else { throw new Error(getErrorMessage(result, 'Ukendt fejl')); } @@ -1193,7 +1244,7 @@ const FinalReview = ({ } finally { setIsGeneratingQuote(false); } - }, [apiBaseUrl, project?.id, project?.project_name, project?.name, project?.customer_name, project?.customer_number, project?.customer_address, editableProjectDescription, normalizedGeometry, packageData, editableMaterials, editableRentalItems, editableLaborTasks, economics, materialTotal, rentalTotal, laborTotal, subtotal, totalProfit, subtotalWithProfit, taxAmount, grandTotal, includeRentalInQuote, orderSuggestions, aiInstructions, pdfUrl, getErrorMessage, buildLocalStaticQuote, notify]); + }, [apiBaseUrl, project?.id, project?.project_name, project?.name, project?.customer_name, project?.customer_number, project?.customer_address, editableProjectDescription, normalizedGeometry, packageData, editableMaterials, editableRentalItems, editableLaborTasks, economics, materialTotal, rentalTotal, laborTotal, subtotal, totalProfit, subtotalWithProfit, taxAmount, grandTotal, includeRentalInQuote, orderSuggestions, aiInstructions, getErrorMessage, buildLocalStaticQuote, notify]); // Generer PDF til preview + valgfri download const handleGeneratePDF = async () => { @@ -1202,6 +1253,7 @@ const FinalReview = ({ return; } setIsGeneratingPDF(true); + const requestedPdfSignature = currentPdfSignature; try { // Send complete quote data with all edited values const quoteData = { @@ -1224,6 +1276,8 @@ const FinalReview = ({ roofArea: normalizedGeometry.roofCoveringArea || calculateRoofArea(normalizedGeometry.width, normalizedGeometry.length, normalizedGeometry.roofPitch) }, materials: editableMaterials.map(material => ({ + materialId: material.materialId ?? material.material_id ?? material.id ?? null, + material_id: material.materialId ?? material.material_id ?? material.id ?? null, name: material.name, quantity: parseFloat(material.quantity) || 0, unit: material.unit, @@ -1276,7 +1330,7 @@ const FinalReview = ({ window.URL.revokeObjectURL(pdfUrl); } setPdfUrl(url); - setIsPdfStale(false); + setGeneratedPdfSignature(requestedPdfSignature); setPdfGeneratedAt(new Date()); markQuoteFlowMilestone({ apiBaseUrl, @@ -1302,6 +1356,7 @@ const FinalReview = ({ }); notify.error('Fejl ved PDF generering: ' + (error.error || 'Ukendt fejl')); setPdfUrl(null); + setGeneratedPdfSignature(null); } } catch (error) { console.error('Error generating PDF:', error); @@ -1313,6 +1368,7 @@ const FinalReview = ({ }); notify.error('Fejl ved PDF generering: ' + error.message); setPdfUrl(null); + setGeneratedPdfSignature(null); } finally { setIsGeneratingPDF(false); } @@ -1354,20 +1410,6 @@ const FinalReview = ({ }; }, [pdfUrl]); - React.useEffect(() => { - if (pdfUrl) { - setIsPdfStale(true); - } - }, [ - quoteText, - editableProjectDescription, - economics, - editableMaterials, - editableRentalItems, - editableLaborTasks, - pdfUrl - ]); - const formatCurrency = formatCurrencyValue; const formatDate = (dateString) => { @@ -1460,6 +1502,16 @@ const FinalReview = ({ )} +

+ {sharedReadiness.readyToSend + ? 'Klar til afsendelse' + : sharedReadiness.readyForReview + ? 'Klar til review — ikke klar til afsendelse' + : 'Flowet mangler trin før review'} + {sharedReadiness.unlinkedMaterialCount > 0 + ? ` · ${sharedReadiness.unlinkedMaterialCount} materiale${sharedReadiness.unlinkedMaterialCount === 1 ? '' : 'r'} kræver kobling` + : ''} +

+ {sharedReadiness.unlinkedMaterialCount > 0 && ( + + )} +
{FLOW_STEPS.map((step, index) => { const isCompleted = index <= activeStageIndex; @@ -2040,19 +2101,16 @@ const FinalReview = ({ {showQuoteEditor && quoteText && (
-