diff --git a/condition-api/src/condition_api/services/condition_service.py b/condition-api/src/condition_api/services/condition_service.py
index 1f2cc5a..528c9be 100644
--- a/condition-api/src/condition_api/services/condition_service.py
+++ b/condition-api/src/condition_api/services/condition_service.py
@@ -942,9 +942,13 @@ def _process_internal_conditions(condition_data):
"year_issued": row.year_issued,
"effective_document_id": row.effective_document_id,
"source_document": row.amendment_name if row.amendment_name
- and row.condition_type == ConditionType.ADD else row.document_label
+ and row.condition_type == ConditionType.ADD else row.document_label,
+ "subconditions": []
}
+ for cond_id in conditions_map:
+ ConditionService._populate_subconditions(conditions_map, cond_id)
+
return ProjectDocumentConditionSchema().dump(
{
"project_name": condition_data[0].project_name,
diff --git a/condition-web/package-lock.json b/condition-web/package-lock.json
index 956181a..82b9fa7 100644
--- a/condition-web/package-lock.json
+++ b/condition-web/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
+ "@fontsource/inter": "^5.2.8",
"@hello-pangea/dnd": "^17.0.0",
"@hookform/resolvers": "^3.9.0",
"@mui/icons-material": "^5.15.21",
@@ -26,6 +27,7 @@
"date-fns": "^2.28.0",
"date-fns-tz": "^1.3.4",
"epic.theme": "latest",
+ "jspdf": "^4.2.0",
"jwt-decode": "^4.0.0",
"keycloak-js": "^25.0.1",
"oidc-client-ts": "^3.0.1",
@@ -1777,12 +1779,10 @@
"peer": true
},
"node_modules/@babel/runtime": {
- "version": "7.26.0",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz",
- "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==",
- "dependencies": {
- "regenerator-runtime": "^0.14.0"
- },
+ "version": "7.28.6",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
+ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -2408,7 +2408,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"optional": true,
"os": [
"aix"
@@ -2424,7 +2423,6 @@
"cpu": [
"arm"
],
- "dev": true,
"optional": true,
"os": [
"android"
@@ -2440,7 +2438,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"android"
@@ -2456,7 +2453,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"android"
@@ -2472,7 +2468,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"darwin"
@@ -2488,7 +2483,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"darwin"
@@ -2504,7 +2498,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"freebsd"
@@ -2520,7 +2513,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"freebsd"
@@ -2536,7 +2528,6 @@
"cpu": [
"arm"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2552,7 +2543,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2568,7 +2558,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2584,7 +2573,6 @@
"cpu": [
"loong64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2600,7 +2588,6 @@
"cpu": [
"mips64el"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2616,7 +2603,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2632,7 +2618,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2648,7 +2633,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2664,7 +2648,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -2680,7 +2663,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"netbsd"
@@ -2696,7 +2678,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"openbsd"
@@ -2712,7 +2693,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"sunos"
@@ -2728,7 +2708,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"win32"
@@ -2744,7 +2723,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"optional": true,
"os": [
"win32"
@@ -2760,7 +2738,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"win32"
@@ -2908,6 +2885,15 @@
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.8.tgz",
"integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig=="
},
+ "node_modules/@fontsource/inter": {
+ "version": "5.2.8",
+ "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
+ "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
+ "license": "OFL-1.1",
+ "funding": {
+ "url": "https://github.com/sponsors/ayuhito"
+ }
+ },
"node_modules/@hello-pangea/dnd": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@hello-pangea/dnd/-/dnd-17.0.0.tgz",
@@ -3584,7 +3570,6 @@
"cpu": [
"arm"
],
- "dev": true,
"optional": true,
"os": [
"android"
@@ -3597,7 +3582,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"android"
@@ -3610,7 +3594,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"darwin"
@@ -3623,7 +3606,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"darwin"
@@ -3636,7 +3618,6 @@
"cpu": [
"arm"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3649,7 +3630,6 @@
"cpu": [
"arm"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3662,7 +3642,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3675,7 +3654,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3688,7 +3666,6 @@
"cpu": [
"ppc64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3701,7 +3678,6 @@
"cpu": [
"riscv64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3714,7 +3690,6 @@
"cpu": [
"s390x"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3727,7 +3702,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3740,7 +3714,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"linux"
@@ -3753,7 +3726,6 @@
"cpu": [
"arm64"
],
- "dev": true,
"optional": true,
"os": [
"win32"
@@ -3766,7 +3738,6 @@
"cpu": [
"ia32"
],
- "dev": true,
"optional": true,
"os": [
"win32"
@@ -3779,7 +3750,6 @@
"cpu": [
"x64"
],
- "dev": true,
"optional": true,
"os": [
"win32"
@@ -4300,6 +4270,12 @@
"undici-types": "~5.26.4"
}
},
+ "node_modules/@types/pako": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
+ "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
+ "license": "MIT"
+ },
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -4310,6 +4286,13 @@
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz",
"integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q=="
},
+ "node_modules/@types/raf": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz",
+ "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/react": {
"version": "18.3.3",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
@@ -4355,6 +4338,13 @@
"integrity": "sha512-0vWLNK2D5MT9dg0iOo8GlKguPAU02QjmZitPEsXRuJXU/OGIOt9vT9Fc26wtYuavLxtO45v9PGleoL9Z0k1LHg==",
"dev": true
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
@@ -5282,6 +5272,16 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true
},
+ "node_modules/base64-arraybuffer": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
+ "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.6.0"
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -5504,6 +5504,26 @@
}
]
},
+ "node_modules/canvg": {
+ "version": "3.0.11",
+ "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz",
+ "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@types/raf": "^3.4.0",
+ "core-js": "^3.8.3",
+ "raf": "^3.4.1",
+ "regenerator-runtime": "^0.13.7",
+ "rgbcolor": "^1.0.1",
+ "stackblur-canvas": "^2.0.0",
+ "svg-pathdata": "^6.0.3"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
@@ -5778,6 +5798,18 @@
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="
},
+ "node_modules/core-js": {
+ "version": "3.49.0",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
+ "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/core-js-compat": {
"version": "3.38.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.0.tgz",
@@ -5835,6 +5867,16 @@
"tiny-invariant": "^1.0.6"
}
},
+ "node_modules/css-line-break": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
+ "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
@@ -6157,6 +6199,16 @@
"csstype": "^3.0.2"
}
},
+ "node_modules/dompurify": {
+ "version": "3.3.3",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
+ "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optional": true,
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/dotenv": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
@@ -6770,6 +6822,17 @@
"integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
"dev": true
},
+ "node_modules/fast-png": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz",
+ "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/pako": "^2.0.3",
+ "iobuffer": "^5.3.2",
+ "pako": "^2.1.0"
+ }
+ },
"node_modules/fast-uri": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz",
@@ -6795,6 +6858,12 @@
"pend": "~1.2.0"
}
},
+ "node_modules/fflate": {
+ "version": "0.8.2",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+ "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+ "license": "MIT"
+ },
"node_modules/figures": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
@@ -7367,6 +7436,20 @@
"integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
"dev": true
},
+ "node_modules/html2canvas": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
+ "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "css-line-break": "^2.1.0",
+ "text-segmentation": "^1.0.3"
+ },
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/http-signature": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.3.6.tgz",
@@ -7423,7 +7506,7 @@
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz",
"integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==",
- "dev": true
+ "devOptional": true
},
"node_modules/import-fresh": {
"version": "3.3.0",
@@ -7492,6 +7575,12 @@
"loose-envify": "^1.0.0"
}
},
+ "node_modules/iobuffer": {
+ "version": "5.4.0",
+ "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz",
+ "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
+ "license": "MIT"
+ },
"node_modules/is-arrayish": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
@@ -7960,6 +8049,23 @@
"graceful-fs": "^4.1.6"
}
},
+ "node_modules/jspdf": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz",
+ "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.28.6",
+ "fast-png": "^6.2.0",
+ "fflate": "^0.8.1"
+ },
+ "optionalDependencies": {
+ "canvg": "^3.0.11",
+ "core-js": "^3.6.0",
+ "dompurify": "^3.3.1",
+ "html2canvas": "^1.0.0-rc.5"
+ }
+ },
"node_modules/jsprim": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/jsprim/-/jsprim-2.0.2.tgz",
@@ -8882,6 +8988,12 @@
"node": ">=8"
}
},
+ "node_modules/pako": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
+ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -8960,7 +9072,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
- "dev": true
+ "devOptional": true
},
"node_modules/picocolors": {
"version": "1.0.1",
@@ -9470,6 +9582,16 @@
}
]
},
+ "node_modules/raf": {
+ "version": "3.4.1",
+ "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
+ "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "performance-now": "^2.1.0"
+ }
+ },
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
@@ -9657,9 +9779,11 @@
}
},
"node_modules/regenerator-runtime": {
- "version": "0.14.1",
- "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
- "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
+ "version": "0.13.11",
+ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
+ "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
+ "license": "MIT",
+ "optional": true
},
"node_modules/regenerator-transform": {
"version": "0.15.2",
@@ -9823,6 +9947,16 @@
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"dev": true
},
+ "node_modules/rgbcolor": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz",
+ "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
+ "license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
+ "optional": true,
+ "engines": {
+ "node": ">= 0.8.15"
+ }
+ },
"node_modules/rifm": {
"version": "0.12.1",
"resolved": "https://registry.npmjs.org/rifm/-/rifm-0.12.1.tgz",
@@ -9944,7 +10078,7 @@
"version": "1.77.8",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.77.8.tgz",
"integrity": "sha512-4UHg6prsrycW20fqLGPShtEvo/WyHRVRHwOP4DzkUrObWoWI05QBSfzU71TVB7PFaL104TwNaHpjlWXAZbQiNQ==",
- "dev": true,
+ "devOptional": true,
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -10250,6 +10384,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/stackblur-canvas": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
+ "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=0.1.14"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -10333,6 +10477,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/svg-pathdata": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
+ "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
+ "license": "MIT",
+ "optional": true,
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
"node_modules/tapable": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz",
@@ -10459,6 +10613,16 @@
"node": "*"
}
},
+ "node_modules/text-segmentation": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
+ "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "utrie": "^1.0.2"
+ }
+ },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -10779,6 +10943,16 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
+ "node_modules/utrie": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
+ "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "base64-arraybuffer": "^1.0.2"
+ }
+ },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
@@ -10961,21 +11135,6 @@
}
}
},
- "node_modules/vite-tsconfig-paths/node_modules/typescript": {
- "version": "5.5.4",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
- "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
- "dev": true,
- "optional": true,
- "peer": true,
- "bin": {
- "tsc": "bin/tsc",
- "tsserver": "bin/tsserver"
- },
- "engines": {
- "node": ">=14.17"
- }
- },
"node_modules/vue-eslint-parser": {
"version": "9.4.3",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz",
diff --git a/condition-web/package.json b/condition-web/package.json
index 3c88a7d..f077f0f 100644
--- a/condition-web/package.json
+++ b/condition-web/package.json
@@ -14,6 +14,7 @@
"dependencies": {
"@emotion/react": "^11.11.4",
"@emotion/styled": "^11.11.5",
+ "@fontsource/inter": "^5.2.8",
"@hello-pangea/dnd": "^17.0.0",
"@hookform/resolvers": "^3.9.0",
"@mui/icons-material": "^5.15.21",
@@ -30,6 +31,7 @@
"date-fns": "^2.28.0",
"date-fns-tz": "^1.3.4",
"epic.theme": "latest",
+ "jspdf": "^4.2.0",
"jwt-decode": "^4.0.0",
"keycloak-js": "^25.0.1",
"oidc-client-ts": "^3.0.1",
diff --git a/condition-web/src/components/ConsolidatedConditions/index.tsx b/condition-web/src/components/ConsolidatedConditions/index.tsx
index 2c63a80..0a9a2bb 100644
--- a/condition-web/src/components/ConsolidatedConditions/index.tsx
+++ b/condition-web/src/components/ConsolidatedConditions/index.tsx
@@ -2,16 +2,19 @@ import { useState, useEffect } from "react";
import { useNavigate } from "@tanstack/react-router";
import { BCDesignTokens } from "epic.theme";
import { ConditionModel } from "@/models/Condition";
-import { Box, FormControlLabel, Grid, styled, Stack, Switch, Typography } from "@mui/material";
+import { Box, Button, FormControlLabel, Grid, styled, Stack, Switch, Typography } from "@mui/material";
import { ContentBoxSkeleton } from "../Shared/ContentBox/ContentBoxSkeleton";
import { ContentBox } from "../Shared/ContentBox";
import ConditionTable from "../Conditions/ConditionsTable";
import { DocumentStatus } from "@/models/Document";
import DocumentStatusChip from "../Projects/DocumentStatusChip";
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined';
+import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
import ConsolidatedConditionFilters from "@/components/Filters/ConsolidatedConditionFilters";
import { useConditionFilters } from "@/components/Filters/conditionFilterStore";
import { CONDITION_STATUS, ConditionStatus } from "@/models/Condition";
+import { generateConsolidatedConditionsPDF } from "@/utils/pdfExport";
+import eaoLogo from "@/assets/images/EAO_Logo.png";
export const CardInnerBox = styled(Box)({
display: "flex",
@@ -47,6 +50,20 @@ export const ConsolidatedConditions = ({
const [isToggled, setIsToggled] = useState(true);
const { filters } = useConditionFilters();
+ const [isExporting, setIsExporting] = useState(false);
+
+ const handleExportPDF = async () => {
+ setIsExporting(true);
+ try {
+ await generateConsolidatedConditionsPDF(
+ filteredConditions || [],
+ projectName,
+ eaoLogo
+ );
+ } finally {
+ setIsExporting(false);
+ }
+ };
const filteredConditions = conditions?.filter((condition) => {
const matchesSearch = filters.search_text
@@ -153,19 +170,41 @@ export const ConsolidatedConditions = ({
- {consolidationLevel != 'project' &&
-
-
- }
- label="View Consolidated Conditions"
- labelPlacement="end"
- />
- }
+
+ {consolidationLevel != 'project' && (
+
+ }
+ label="View Consolidated Conditions"
+ labelPlacement="end"
+ />
+ )}
+ }
+ onClick={handleExportPDF}
+ disabled={isExporting || !filteredConditions?.length}
+ sx={{
+ whiteSpace: "nowrap",
+ backgroundColor: "#0d2b4e",
+ color: "#ffffff",
+ "&:hover": { backgroundColor: "#0a2240" },
+ "&:disabled": { backgroundColor: "#0d2b4e", opacity: 0.5, color: "#ffffff" },
+ borderRadius: "4px",
+ textTransform: "none",
+ fontWeight: 500,
+ px: 2,
+ py: 0.75,
+ }}
+ >
+ {isExporting ? "Exporting..." : "Export PDF"}
+
+
PH - MB - DISC_H - 3) newPage();
+}
+
+function setFont(
+ size: number,
+ style: "normal" | "bold" | "italic" | "bolditalic" = "normal",
+ color: RGB = C.DARK
+) {
+ _doc.setFontSize(size);
+ _doc.setFont("helvetica", style);
+ _doc.setTextColor(color[0], color[1], color[2]);
+}
+
+/** Line height in mm for a given pt size */
+function lh(pt: number, mult = 1.35): number {
+ return pt * 0.3528 * mult;
+}
+
+function drawRect(
+ x: number, y: number, w: number, h: number,
+ fill?: RGB, stroke?: RGB, strokeW = 0.2
+) {
+ if (fill) _doc.setFillColor(fill[0], fill[1], fill[2]);
+ if (stroke) { _doc.setDrawColor(stroke[0], stroke[1], stroke[2]); _doc.setLineWidth(strokeW); }
+ _doc.rect(x, y, w, h, fill && stroke ? "FD" : fill ? "F" : "D");
+}
+
+function drawHRule(x1: number, y: number, x2: number, color: RGB = C.LIGHT_GRAY, w = 0.2) {
+ _doc.setDrawColor(color[0], color[1], color[2]);
+ _doc.setLineWidth(w);
+ _doc.line(x1, y, x2, y);
+}
+
+// ─── Inline chip helpers ───────────────────────────────────────────────────────
+
+const CHIP_H = 7.94; // mm (30px = 5+20+5px padding+line-height+padding)
+const CHIP_PL = 3.44; // left padding mm (13px)
+const CHIP_PR = 3.27; // right padding mm (12.359px)
+const CHIP_FONT = 10.5; // pt (14px)
+const CHIP_R = 1.06; // border-radius mm (4px)
+// Text baseline: chip top + top-pad(1.32) + line-height(5.29) - descender(~1.3) ≈ top + 5.3
+const CHIP_TEXT_DY = 5.3;
+
+/** Returns the chip width in mm */
+function chipWidth(label: string): number {
+ _doc.setFontSize(CHIP_FONT);
+ _doc.setFont("helvetica", "normal");
+ return _doc.getTextWidth(label) + CHIP_PL + CHIP_PR;
+}
+
+/**
+ * Draw a status chip (Approved / Awaiting Approval).
+ * x, y = top-left corner of chip.
+ */
+function drawStatusChip(
+ label: string,
+ isApproved: boolean | undefined,
+ x: number,
+ y: number
+) {
+ const border = isApproved === true ? C.GREEN_BORDER : C.GOLD_BORDER;
+ const bg = isApproved === true ? C.GREEN_BG : C.GOLD_BG;
+ const textColor = isApproved === true ? C.GREEN_TEXT : C.GOLD_TEXT;
+ const cw = chipWidth(label);
+ _doc.setFillColor(bg[0], bg[1], bg[2]);
+ _doc.setDrawColor(border[0], border[1], border[2]);
+ _doc.setLineWidth(0.35);
+ _doc.roundedRect(x, y, cw, CHIP_H, CHIP_R, CHIP_R, "FD");
+ setFont(CHIP_FONT, "normal", textColor);
+ _doc.text(label, x + CHIP_PL, y + CHIP_TEXT_DY);
+}
+
+/**
+ * Draw a blue "Amended Version" chip.
+ * "Amended Version:" prefix is bold-italic; the amendment name is normal weight.
+ * x, y = top-left corner of chip.
+ */
+function drawAmendChip(label: string, x: number, y: number) {
+ const PREFIX = "Amended Version: ";
+ const hasPrefix = label.startsWith(PREFIX);
+ const suffix = hasPrefix ? label.slice(PREFIX.length) : label;
+
+ // Measure each segment with its own font style for accurate width
+ _doc.setFontSize(CHIP_FONT);
+ _doc.setFont("helvetica", "bolditalic");
+ const prefixW = hasPrefix ? _doc.getTextWidth(PREFIX) : 0;
+ _doc.setFont("helvetica", "normal");
+ const suffixW = _doc.getTextWidth(hasPrefix ? suffix : label);
+
+ const cw = prefixW + suffixW + CHIP_PL + CHIP_PR;
+ _doc.setFillColor(C.BLUE_BG[0], C.BLUE_BG[1], C.BLUE_BG[2]);
+ _doc.setDrawColor(C.BC_BLUE[0], C.BC_BLUE[1], C.BC_BLUE[2]);
+ _doc.setLineWidth(0.35);
+ _doc.roundedRect(x, y, cw, CHIP_H, CHIP_R, CHIP_R, "FD");
+
+ const textY = y + CHIP_TEXT_DY;
+ if (hasPrefix) {
+ setFont(CHIP_FONT, "bolditalic", C.BC_BLUE);
+ _doc.text(PREFIX, x + CHIP_PL, textY);
+ setFont(CHIP_FONT, "normal", C.BC_BLUE);
+ _doc.text(suffix, x + CHIP_PL + prefixW, textY);
+ } else {
+ setFont(CHIP_FONT, "normal", C.BC_BLUE);
+ _doc.text(label, x + CHIP_PL, textY);
+ }
+}
+
+// ─── Header section ────────────────────────────────────────────────────────────
+
+function addHeader(
+ projectName: string,
+ conditions: ConditionModel[],
+ eaoLogoData?: string
+) {
+ _y = MT;
+
+ // ── Logo (left) and title (right) ───────────────────────────────────────────
+ const titleAreaW = CW * 0.55;
+
+ // EAO logo only (already contains BC logo) — 516×96px → 70×13mm (ratio 5.375:1)
+ const LOGO_W = 70;
+ const LOGO_H = 13;
+ if (eaoLogoData) {
+ try { _doc.addImage(eaoLogoData, "PNG", ML, _y + 2, LOGO_W, LOGO_H); } catch { /* ignore */ }
+ }
+
+ // "Consolidated Conditions" — right-aligned
+ _doc.setFont("helvetica", "bold");
+ _doc.setFontSize(22);
+ _doc.setTextColor(C.DARK[0], C.DARK[1], C.DARK[2]);
+ _doc.text("Consolidated Conditions", PW - MR, _y + 8, { align: "right" });
+
+ // Project name — right-aligned under title
+ _doc.setFontSize(15);
+ _doc.setFont("helvetica", "normal");
+ _doc.setTextColor(C.SUBTITLE[0], C.SUBTITLE[1], C.SUBTITLE[2]);
+ const projLines = _doc.splitTextToSize(projectName, titleAreaW) as string[];
+ const projLineH = lh(15, 1.3);
+ for (let i = 0; i < projLines.length; i++) {
+ _doc.text(projLines[i], PW - MR, _y + 17 + i * projLineH, { align: "right" });
+ }
+
+ // Status chip — right-aligned below project name
+ const allApproved = conditions.every((c) => c.is_approved === true);
+ const overallLabel = allApproved ? "Approved" : "Awaiting Approval";
+ const chipY = _y + 17 + projLines.length * projLineH - 2;
+ const chipX = PW - MR - chipWidth(overallLabel);
+ drawStatusChip(overallLabel, allApproved, chipX, chipY);
+
+ _y += 17 + projLines.length * projLineH + CHIP_H + 6; // dynamic row height + gap
+
+ // ── Horizontal separator ──────────────────────────────────────────────────────
+ drawHRule(ML, _y, PW - MR, C.SEPARATOR, 0.5);
+ _y += 9; // 33px gap after separator → stats
+
+ // ── Stats ─────────────────────────────────────────────────────────────────────
+ const today = format(new Date(), "EEEE, MMMM d, yyyy");
+ const totalConditions = conditions.length;
+
+ // Collect unique amendment names from all conditions
+ const amendmentSet = new Set();
+ conditions.forEach((c) => {
+ if (c.amendment_names) {
+ c.amendment_names.split(",").forEach((a) => {
+ const trimmed = a.trim();
+ if (trimmed) amendmentSet.add(trimmed);
+ });
+ }
+ });
+ const amendmentList = Array.from(amendmentSet).join(", ");
+
+ const statsRows: [string, string][] = [
+ ["Generated on:", today],
+ ["Total Conditions:", String(totalConditions)],
+ ...(amendmentList ? [["Included Amendments:", amendmentList] as [string, string]] : []),
+ ];
+
+ const statLabelW = 50;
+ for (const [label, value] of statsRows) {
+ setFont(10.5, "bold", C.DARK);
+ _doc.text(label, ML, _y);
+ setFont(10.5, "normal", C.DARK);
+ const valueLines = _doc.splitTextToSize(value, CW - statLabelW) as string[];
+ for (let i = 0; i < valueLines.length; i++) {
+ _doc.text(valueLines[i], ML + statLabelW, _y + i * lh(10.5, 1.3));
+ }
+ _y += valueLines.length * lh(10.5, 1.3) + 2.1; // 8px row gap
+ }
+
+ _y += 8.47; // 32px gap stats → info box
+
+ // ── Info / disclaimer box ─────────────────────────────────────────────────────
+ // Figma: padding 16px top / 16px right / 16px bottom / 20px left, gap 12px between paras
+ // border-l-4 (4px = 1.06mm) + padding-left 20px = 5.29mm → text at ML + 6.35mm
+ const BOX_ACCENT = 1.06; // 4px border-left
+ const BOX_PL = 5.29; // 20px padding-left (after accent)
+ const BOX_PR = 4.23; // 16px padding-right
+ const BOX_PT_TEXT = 7.0; // baseline offset: 16px pad + cap-height(~2.7mm) so visual top aligns
+ const BOX_PB = 4.23; // 16px padding-bottom (same as top)
+ const BOX_GAP = 3.17; // 12px gap between paragraphs
+ const BOX_TEXT_X = ML + BOX_ACCENT + BOX_PL;
+ const BOX_TEXT_W = CW - BOX_ACCENT - BOX_PL - BOX_PR;
+ const boxLineH = lh(10.5, 1.4);
+
+ const para1 =
+ "This Consolidated Conditions document contains the most recent condition information as " +
+ "entered in the Condition Repository. This is not an official or enforceable document and " +
+ "should be used supplementary to official documents.";
+ const para2 =
+ "Note: Conditions with the status \u2018Awaiting Approval\u2019 have not been approved by a staff member.";
+
+ setFont(10.5, "normal", C.SUBTITLE);
+ const para1Lines = _doc.splitTextToSize(para1, BOX_TEXT_W) as string[];
+ const para2Lines = _doc.splitTextToSize(para2, BOX_TEXT_W) as string[];
+
+ const boxH = BOX_PT_TEXT
+ + para1Lines.length * boxLineH
+ + BOX_GAP
+ + para2Lines.length * boxLineH
+ + BOX_PB;
+
+ const boxStartY = _y;
+
+ // Background + left accent bar
+ drawRect(ML, _y, CW, boxH, C.INFO_BOX_BG);
+ _doc.setFillColor(C.BLUE_ACCENT[0], C.BLUE_ACCENT[1], C.BLUE_ACCENT[2]);
+ _doc.rect(ML, _y, BOX_ACCENT, boxH, "F");
+
+ _y += BOX_PT_TEXT;
+
+ for (const line of para1Lines) {
+ _doc.text(line, BOX_TEXT_X, _y);
+ _y += boxLineH;
+ }
+
+ _y += BOX_GAP;
+
+ // "Note:" bold, rest normal — matches Figma bold+normal inline style
+ setFont(10.5, "bold", C.SUBTITLE);
+ const notePrefix = "Note: ";
+ const notePrefixW = _doc.getTextWidth(notePrefix);
+ _doc.text(notePrefix, BOX_TEXT_X, _y);
+ setFont(10.5, "normal", C.SUBTITLE);
+ const noteBody = para2Lines[0].replace(/^Note: /, "");
+ _doc.text(noteBody, BOX_TEXT_X + notePrefixW, _y);
+ for (let i = 1; i < para2Lines.length; i++) {
+ _y += boxLineH;
+ _doc.text(para2Lines[i], BOX_TEXT_X, _y);
+ }
+
+ _y = boxStartY + boxH + 12.7; // bottom of box + gap to first condition
+}
+
+// ─── Subconditions ─────────────────────────────────────────────────────────────
+
+function renderSubconditions(
+ subconditions: SubconditionModel[],
+ baseX: number,
+ level: number
+) {
+ const identW = level === 0 ? 12 : 7; // level-1 letter col: ~3.7mm + 3.2mm gap
+ const textX = baseX + identW;
+ const textW = CW - (baseX - ML) - identW;
+ // Figma: 16px/400/#101828, line-height 24px = 6.35mm
+ const lineH = 6.35; // 24px exact line-height
+ const itemGap = level === 0 ? 6.4 : 2.1; // 24px between numbered, 8px between lettered
+
+ const sorted = [...subconditions].sort((a, b) => a.sort_order - b.sort_order);
+
+ for (const sub of sorted) {
+ setFont(12, "normal", C.CONTENT);
+ const lines = _doc.splitTextToSize(sub.subcondition_text || "", textW) as string[];
+ const neededH = lines.length * lineH + itemGap;
+
+ ensureSpace(neededH + 1);
+
+ // Identifier (bold, same color)
+ setFont(12, "bold", C.CONTENT);
+ _doc.text(sub.subcondition_identifier || "", baseX, _y);
+
+ // Text (first line shares y with identifier)
+ setFont(12, "normal", C.CONTENT);
+ for (let i = 0; i < lines.length; i++) {
+ _doc.text(lines[i], textX, _y);
+ _y += lineH;
+ }
+ _y += itemGap; // 24px between numbered items, 8px between lettered
+
+ if (sub.subconditions && sub.subconditions.length > 0) {
+ renderSubconditions(sub.subconditions, baseX + 8.5, level + 1); // 32px indent
+ }
+ }
+}
+
+// ─── Single condition block ─────────────────────────────────────────────────────
+
+function renderCondition(condition: ConditionModel) {
+ const isApproved = condition.is_approved === true;
+ const statusLabel = isApproved ? "Approved" : "Awaiting Approval";
+ const hasAmendment = !!(condition.amendment_names?.trim());
+ const amendLabel = hasAmendment ? `Amended Version: ${condition.amendment_names}` : "";
+
+ // Estimate minimum space needed to avoid orphaned heading
+ ensureSpace(32);
+
+ // ── Condition heading ─────────────────────────────────────────────────────────
+ // Figma: 24px/500/line-height 32px → 18pt normal, lineH = 8.47mm
+ const heading = `${condition.condition_number}. ${condition.condition_name}`;
+ const headingPt = 18;
+ const headingLineH = 8.47; // 32px exact line-height from Figma
+
+ setFont(headingPt, "normal", C.DARK);
+ const headingLines = _doc.splitTextToSize(heading, CW) as string[];
+
+ ensureSpace(headingLines.length * headingLineH + CHIP_H + 8);
+
+ for (let i = 0; i < headingLines.length; i++) {
+ _doc.text(headingLines[i], ML, _y);
+ _y += i < headingLines.length - 1 ? headingLineH : 3; // 3mm gap before chips
+ }
+
+ // ── Amended chip (if present) — below heading ────────────────────────────────
+ if (hasAmendment) {
+ drawAmendChip(amendLabel, ML, _y);
+ _y += CHIP_H + 5;
+ } else {
+ _y += 3; // small gap before metadata card when no chip
+ }
+
+ // ── Metadata card ─────────────────────────────────────────────────────────────
+ const CARD_PAD = 4.5; // 17px padding
+ const CARD_H = 14.29; // 54px minimum height
+ const CARD_R = 1.06; // 4px border-radius
+ const cardTextX = ML + CARD_PAD;
+ const cardTextW = CW - CARD_PAD * 2;
+
+ const sourceText = condition.source_document || "";
+ const yearText = String(condition.year_issued ?? "");
+
+ // Measure widths BEFORE drawing (needed for dynamic height)
+ setFont(10.5, "normal", C.GRAY);
+ const yearLabelW = _doc.getTextWidth("Year Condition Issued:") + 1.5; // +1.5mm explicit space
+ const srcLabelW = _doc.getTextWidth("Source Document:") + 1.5; // +1.5mm explicit space
+ const rowLineH = 5.29; // 20px line-height
+ const srcLines = _doc.splitTextToSize(sourceText, cardTextW - srcLabelW) as string[];
+
+ // Dynamic height: row1 (year) + row2 (source, may wrap) + padding
+ const dynamicCardH = Math.max(
+ CARD_H,
+ CARD_PAD + rowLineH + 3 + srcLines.length * rowLineH + CARD_PAD
+ );
+
+ ensureSpace(dynamicCardH + 4);
+ const cardY = _y;
+ const row1Y = cardY + CARD_PAD + 4.0; // Year row baseline
+ const row2Y = row1Y + rowLineH + 3; // Source row baseline
+
+ // Card background
+ _doc.setFillColor(C.CARD_BG[0], C.CARD_BG[1], C.CARD_BG[2]);
+ _doc.setDrawColor(C.CARD_BORDER[0], C.CARD_BORDER[1], C.CARD_BORDER[2]);
+ _doc.setLineWidth(0.2);
+ _doc.roundedRect(ML, cardY, CW, dynamicCardH, CARD_R, CARD_R, "FD");
+
+ // Row 1 — Year Condition Issued (left) + status chip (right)
+ setFont(10.5, "normal", C.GRAY);
+ _doc.text("Year Condition Issued:", cardTextX, row1Y);
+ setFont(10.5, "normal", C.CONTENT);
+ _doc.text(yearText, cardTextX + yearLabelW, row1Y);
+
+ // Status chip — vertically aligned with row 1 text
+ const statusChipX = ML + CW - CARD_PAD - chipWidth(statusLabel);
+ const statusChipY = row1Y - CHIP_TEXT_DY;
+ drawStatusChip(statusLabel, isApproved, statusChipX, statusChipY);
+
+ // Row 2 — Source Document (stacked below Year, full width)
+ setFont(10.5, "normal", C.GRAY);
+ _doc.text("Source Document:", cardTextX, row2Y);
+ setFont(10.5, "normal", C.CONTENT);
+ _doc.text(srcLines[0] ?? "", cardTextX + srcLabelW, row2Y);
+ for (let i = 1; i < srcLines.length; i++) {
+ _doc.text(srcLines[i], cardTextX, row2Y + i * rowLineH);
+ }
+
+ _y = cardY + dynamicCardH + 6.4; // 24px gap metadata → content
+
+ // ── Condition text (if present before subconditions) ──────────────────────────
+ if (condition.condition_text?.trim()) {
+ setFont(12, "normal", C.DARK);
+ const txtLines = _doc.splitTextToSize(condition.condition_text, CW) as string[];
+ for (const line of txtLines) {
+ ensureSpace(lh(12, 1.5) + 1);
+ _doc.text(line, ML, _y);
+ _y += lh(12, 1.5);
+ }
+ _y += 6.4; // 24px gap before subconditions
+ }
+
+ // ── Subconditions ─────────────────────────────────────────────────────────────
+ if (condition.subconditions && condition.subconditions.length > 0) {
+ renderSubconditions(condition.subconditions, ML, 0);
+ }
+
+ _y += 12.7; // ~50px space before separator
+
+ // ── Bottom separator ──────────────────────────────────────────────────────────
+ drawHRule(ML, _y, PW - MR, C.SEPARATOR, 0.5);
+ _y += 12.7; // 48px gap between condition blocks
+}
+
+// ─── Image loader ──────────────────────────────────────────────────────────────
+
+async function loadImageAsDataUrl(url: string): Promise {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.crossOrigin = "anonymous";
+ img.onload = () => {
+ const canvas = document.createElement("canvas");
+ canvas.width = img.naturalWidth;
+ canvas.height = img.naturalHeight;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) { reject(new Error("No canvas context")); return; }
+ ctx.drawImage(img, 0, 0);
+ resolve(canvas.toDataURL("image/png"));
+ };
+ img.onerror = () => reject(new Error(`Failed to load: ${url}`));
+ img.src = url;
+ });
+}
+
+// ─── Main export function ──────────────────────────────────────────────────────
+
+export async function generateConsolidatedConditionsPDF(
+ conditions: ConditionModel[],
+ projectName: string,
+ eaoLogoUrl?: string
+): Promise {
+ _doc = new jsPDF("portrait", "mm", "a4");
+ _y = MT;
+
+ let eaoLogoData: string | undefined;
+
+ if (eaoLogoUrl) { try { eaoLogoData = await loadImageAsDataUrl(eaoLogoUrl); } catch { /* ignore */ } }
+
+ // Header (logos, title, stats, disclaimer)
+ addHeader(projectName, conditions, eaoLogoData);
+
+ // Conditions — continuous flow
+ for (const condition of conditions) {
+ renderCondition(condition);
+ }
+
+ // Disclaimer on every page (last page drawn here; others drawn in newPage())
+ drawDisclaimer();
+
+ const safeName = projectName.replace(/[^a-z0-9]/gi, "_").replace(/_+/g, "_");
+ _doc.save(`Consolidated_Conditions_${safeName}.pdf`);
+}