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 && (
-
- {isLoadingProjectData ? 'Synkroniserer projektdata...' : nextMissingStep ? `Næste: ${nextMissingStep.label}` : 'Alt er klar til review'}
+ {isLoadingProjectData
+ ? 'Synkroniserer projektdata...'
+ : nextMissingStep
+ ? `Næste: ${nextMissingStep.label}`
+ : projectReadiness.readyToSend
+ ? 'Klar til afsendelse'
+ : 'Klar til review — ikke klar til afsendelse'}