Compare commits

...

10 Commits

Author SHA1 Message Date
diallolatoile
fb9a9dfba2 feat: Add Health Check Endpoint 2026-01-17 14:06:26 +00:00
diallolatoile
326b9c8ec1 feat: Add Health Check Endpoint 2026-01-17 13:27:07 +00:00
diallolatoile
de4c725554 feat: Add Health Check Endpoint 2026-01-17 11:50:22 +00:00
diallolatoile
5044aa7573 feat: Add Health Check Endpoint 2026-01-17 11:36:05 +00:00
diallolatoile
d26feb396f feat: Manage Images using Minio Service 2026-01-13 03:49:10 +00:00
diallolatoile
a45f4b151c feat: Manage Images using Minio Service 2026-01-11 19:54:37 +00:00
diallolatoile
f8aa8eb595 feat: Manage Images using Minio Service 2026-01-11 04:14:08 +00:00
diallolatoile
47f09b3c4e feat: Manage Images using Minio Service 2026-01-08 01:22:36 +00:00
diallolatoile
754244345a feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2026-01-07 22:21:24 +00:00
diallolatoile
f38056fc3f feat: add DCB User Service API - Authentication system with KEYCLOAK - Modular architecture with services for each feature 2026-01-07 22:11:15 +00:00
89 changed files with 3848 additions and 2649 deletions

513
package-lock.json generated
View File

@ -54,6 +54,7 @@
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"mermaid": "^11.12.2",
"minio": "^8.0.6",
"ng-otp-input": "^2.0.9",
"ng2-charts": "^8.0.0",
"ngx-countup": "^13.2.0",
@ -74,6 +75,8 @@
"@types/jquery": "^3.5.33",
"@types/leaflet": "^1.9.21",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.0.3",
"baseline-browser-mapping": "^2.9.11",
"jasmine-core": "~5.12.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",
@ -4697,9 +4700,9 @@
}
},
"node_modules/@types/node": {
"version": "24.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"version": "25.0.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.3.tgz",
"integrity": "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -4746,6 +4749,13 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/@zxing/text-encoding": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
"license": "(Unlicense OR Apache-2.0)",
"optional": true
},
"node_modules/abbrev": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz",
@ -4953,6 +4963,27 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
"license": "MIT"
},
"node_modules/available-typed-arrays": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
"integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
"license": "MIT",
"dependencies": {
"possible-typed-array-names": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@ -4971,9 +5002,9 @@
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.19.tgz",
"integrity": "sha512-zoKGUdu6vb2jd3YOq0nnhEDQVbPcHhco3UImJrv5dSkvxTc2pl2WjOPsjZXDwPDSl5eghIMuY3R6J9NDKF3KcQ==",
"version": "2.9.11",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.11.tgz",
"integrity": "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
@ -5012,6 +5043,29 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/block-stream2": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
"integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==",
"license": "MIT",
"dependencies": {
"readable-stream": "^3.4.0"
}
},
"node_modules/block-stream2/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
@ -5096,6 +5150,12 @@
"node": ">=8"
}
},
"node_modules/browser-or-node": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz",
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
"license": "MIT"
},
"node_modules/browserslist": {
"version": "4.27.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz",
@ -5129,6 +5189,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
}
},
"node_modules/buffer-crc32": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
@ -5261,11 +5330,28 @@
"node": ">=18"
}
},
"node_modules/call-bind": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.0",
"es-define-property": "^1.0.0",
"get-intrinsic": "^1.2.4",
"set-function-length": "^1.2.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@ -5279,7 +5365,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -6451,6 +6536,32 @@
}
}
},
"node_modules/decode-uri-component": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/define-data-property": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0",
"es-errors": "^1.3.0",
"gopd": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/delaunator": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
@ -6590,7 +6701,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@ -6821,7 +6931,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -6831,7 +6940,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -6841,7 +6949,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@ -7057,6 +7164,24 @@
],
"license": "BSD-3-Clause"
},
"node_modules/fast-xml-parser": {
"version": "4.5.3",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"strnum": "^1.1.1"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -7087,6 +7212,15 @@
"node": ">=8"
}
},
"node_modules/filter-obj": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/finalhandler": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
@ -7139,6 +7273,21 @@
}
}
},
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
"integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
"license": "MIT",
"dependencies": {
"is-callable": "^1.2.7"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@ -7230,7 +7379,6 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@ -7245,6 +7393,15 @@
"node": ">=10"
}
},
"node_modules/generator-function": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
"integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@ -7279,7 +7436,6 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@ -7304,7 +7460,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@ -7360,7 +7515,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -7392,11 +7546,22 @@
"node": ">=8"
}
},
"node_modules/has-property-descriptors": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
"license": "MIT",
"dependencies": {
"es-define-property": "^1.0.0"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -7409,7 +7574,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@ -7425,7 +7589,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@ -7700,6 +7863,22 @@
"node": ">= 0.10"
}
},
"node_modules/is-arguments": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@ -7713,6 +7892,18 @@
"node": ">=8"
}
},
"node_modules/is-callable": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
@ -7752,6 +7943,25 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
"integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.4",
"generator-function": "^2.0.0",
"get-proto": "^1.0.1",
"has-tostringtag": "^1.0.2",
"safe-regex-test": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@ -7799,7 +8009,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@ -7814,6 +8023,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-typed-array": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz",
"integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
"license": "MIT",
"dependencies": {
"which-typed-array": "^1.1.16"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-unicode-supported": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
@ -8917,7 +9141,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@ -9075,6 +9298,67 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minio": {
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.6.tgz",
"integrity": "sha512-sOeh2/b/XprRmEtYsnNRFtOqNRTPDvYtMWh+spWlfsuCV/+IdxNeKVUMKLqI7b5Dr07ZqCPuaRGU/rB9pZYVdQ==",
"license": "Apache-2.0",
"dependencies": {
"async": "^3.2.4",
"block-stream2": "^2.1.0",
"browser-or-node": "^2.1.1",
"buffer-crc32": "^1.0.0",
"eventemitter3": "^5.0.1",
"fast-xml-parser": "^4.4.1",
"ipaddr.js": "^2.0.1",
"lodash": "^4.17.21",
"mime-types": "^2.1.35",
"query-string": "^7.1.3",
"stream-json": "^1.8.0",
"through2": "^4.0.2",
"web-encoding": "^1.1.5",
"xml2js": "^0.5.0 || ^0.6.2"
},
"engines": {
"node": "^16 || ^18 || >=20"
}
},
"node_modules/minio/node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/minio/node_modules/ipaddr.js": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz",
"integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/minio/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minio/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@ -10182,6 +10466,15 @@
"points-on-curve": "0.2.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
"integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@ -10320,6 +10613,24 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
"license": "MIT",
"dependencies": {
"decode-uri-component": "^0.2.2",
"filter-obj": "^1.1.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/quill": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
@ -10639,7 +10950,6 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz",
"integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
@ -10680,6 +10990,12 @@
"@parcel/watcher": "^2.4.1"
}
},
"node_modules/sax": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.3.tgz",
"integrity": "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ==",
"license": "BlueOak-1.0.0"
},
"node_modules/semver": {
"version": "7.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
@ -10732,6 +11048,23 @@
"node": ">= 18"
}
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
"license": "MIT",
"dependencies": {
"define-data-property": "^1.1.4",
"es-errors": "^1.3.0",
"function-bind": "^1.1.2",
"get-intrinsic": "^1.2.4",
"gopd": "^1.0.1",
"has-property-descriptors": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
@ -11179,6 +11512,15 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/ssri": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz",
@ -11215,6 +11557,21 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stream-chain": {
"version": "2.2.5",
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
"integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
"license": "BSD-3-Clause"
},
"node_modules/stream-json": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
"integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
"license": "BSD-3-Clause",
"dependencies": {
"stream-chain": "^2.2.5"
}
},
"node_modules/streamroller": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
@ -11230,6 +11587,15 @@
"node": ">=8.0"
}
},
"node_modules/strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@ -11357,6 +11723,18 @@
"node": ">=8"
}
},
"node_modules/strnum": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/stylis": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz",
@ -11490,6 +11868,29 @@
"dev": true,
"license": "ISC"
},
"node_modules/through2": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
"license": "MIT",
"dependencies": {
"readable-stream": "3"
}
},
"node_modules/through2/node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/tinyexec": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
@ -11743,6 +12144,19 @@
"node": ">=6"
}
},
"node_modules/util": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
"integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"is-arguments": "^1.0.4",
"is-generator-function": "^1.0.7",
"is-typed-array": "^1.1.3",
"which-typed-array": "^1.1.2"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@ -11985,6 +12399,18 @@
"license": "MIT",
"optional": true
},
"node_modules/web-encoding": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/web-encoding/-/web-encoding-1.1.5.tgz",
"integrity": "sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==",
"license": "MIT",
"dependencies": {
"util": "^0.12.3"
},
"optionalDependencies": {
"@zxing/text-encoding": "0.9.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -12001,6 +12427,27 @@
"node": ">= 8"
}
},
"node_modules/which-typed-array": {
"version": "1.1.19",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz",
"integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==",
"license": "MIT",
"dependencies": {
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"for-each": "^0.3.5",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-tostringtag": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
@ -12206,6 +12653,28 @@
}
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@ -57,6 +57,7 @@
"leaflet": "^1.9.4",
"lodash-es": "^4.17.21",
"mermaid": "^11.12.2",
"minio": "^8.0.6",
"ng-otp-input": "^2.0.9",
"ng2-charts": "^8.0.0",
"ngx-countup": "^13.2.0",
@ -77,6 +78,8 @@
"@types/jquery": "^3.5.33",
"@types/leaflet": "^1.9.21",
"@types/lodash-es": "^4.17.12",
"@types/node": "^25.0.3",
"baseline-browser-mapping": "^2.9.11",
"jasmine-core": "~5.12.0",
"karma": "~6.4.4",
"karma-chrome-launcher": "~3.2.0",

View File

@ -646,3 +646,619 @@
font-size: 0.8125rem;
}
}
// ==================== STYLES POUR LA GESTION DES LOGOS ====================
// merchant-config.component.scss
// Variables
$logo-size-sm: 48px;
$logo-size-md: 80px;
$logo-size-lg: 120px;
$logo-size-xl: 200px;
$border-radius-sm: 0.25rem;
$border-radius-md: 0.5rem;
$border-radius-lg: 0.75rem;
$transition-base: all 0.3s ease;
// ==================== UPLOAD DE LOGO ====================
.logo-upload-section {
margin-bottom: 1.5rem;
.logo-upload-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.logo-preview-area {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
.logo-preview {
position: relative;
width: 200px;
height: 200px;
border: 2px solid #e9ecef;
border-radius: 0.5rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.btn-remove-preview {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(220, 53, 69, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(220, 53, 69, 1);
transform: scale(1.1);
}
i {
font-size: 16px;
}
}
}
.logo-placeholder {
width: 200px;
height: 200px;
border: 2px dashed #dee2e6;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #6c757d;
background: #f8f9fa;
i {
opacity: 0.5;
margin-bottom: 1rem;
}
p {
margin: 0;
font-size: 0.875rem;
}
}
.logo-upload-actions {
text-align: center;
.btn-select-logo {
cursor: pointer;
display: inline-block;
}
}
.upload-progress {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: #e7f3ff;
border-radius: 0.25rem;
margin-top: 0.5rem;
.spinner-border {
width: 1rem;
height: 1rem;
}
}
}
// ==================== ÉDITION DE LOGO ====================
.logo-edit-section {
.logo-edit-container {
display: flex;
flex-direction: column;
gap: 1rem;
}
.logo-display-area {
display: flex;
justify-content: center;
align-items: center;
min-height: 150px;
}
.logo-preview {
position: relative;
width: 150px;
height: 150px;
border: 2px solid #e9ecef;
border-radius: 0.5rem;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
background: #f8f9fa;
img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.logo-badge {
position: absolute;
top: 0.5rem;
left: 0.5rem;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
&.badge-new {
background: #28a745;
color: white;
}
&.badge-current {
background: #007bff;
color: white;
}
}
.btn-remove-preview {
position: absolute;
top: 0.5rem;
right: 0.5rem;
background: rgba(220, 53, 69, 0.9);
color: white;
border: none;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: rgba(220, 53, 69, 1);
transform: scale(1.1);
}
}
}
.logo-placeholder {
width: 150px;
height: 150px;
border: 2px dashed #dee2e6;
border-radius: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #6c757d;
background: #f8f9fa;
i {
opacity: 0.5;
margin-bottom: 0.5rem;
}
}
.logo-edit-actions {
display: flex;
gap: 0.5rem;
justify-content: center;
flex-wrap: wrap;
.btn-change-logo,
.btn-remove-logo {
cursor: pointer;
}
}
}
// ==================== AFFICHAGE DES LOGOS DANS LA LISTE ====================
.merchant-logo {
width: 48px;
height: 48px;
object-fit: cover;
border-radius: 0.25rem;
border: 1px solid #dee2e6;
background: #f8f9fa;
}
.merchant-logo-large {
max-width: 200px;
max-height: 200px;
object-fit: contain;
border-radius: 0.5rem;
border: 2px solid #e9ecef;
background: #f8f9fa;
}
.avatar-container {
position: relative;
display: inline-block;
.merchant-logo {
display: block;
}
}
// ==================== ÉTATS DE CHARGEMENT ====================
.logo-loading {
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 24px;
height: 24px;
border: 3px solid rgba(0, 0, 0, 0.1);
border-top-color: #007bff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
}
@keyframes spin {
to {
transform: translate(-50%, -50%) rotate(360deg);
}
}
// ==================== RESPONSIVE ====================
@media (max-width: 768px) {
.logo-upload-section,
.logo-edit-section {
.logo-preview,
.logo-placeholder {
width: 150px;
height: 150px;
}
}
.merchant-logo-large {
max-width: 150px;
max-height: 150px;
}
}
// ==================== ANIMATIONS ====================
.logo-preview img,
.merchant-logo {
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
}
// ==================== ERREURS ====================
.alert-danger {
margin-top: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
i {
margin-right: 0.5rem;
}
}
.merchant-logo-container {
width: 120px;
height: 120px;
flex-shrink: 0;
}
.merchant-logo-large {
width: 100%;
height: 100%;
object-fit: cover;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.merchant-logo-large-placeholder {
width: 100%;
height: 100%;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.profile-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
}
.stats-card {
text-align: center;
padding: 1.5rem;
border-radius: 0.5rem;
background: white;
border: 1px solid #e9ecef;
transition: all 0.3s ease;
}
.stats-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.stats-icon {
font-size: 1.5rem;
}
.stats-number {
font-size: 2rem;
font-weight: bold;
color: #495057;
}
.stats-label {
color: #6c757d;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
@media (max-width: 768px) {
.merchant-logo-container {
width: 80px;
height: 80px;
}
.profile-header {
padding: 1.5rem;
}
.profile-header h2 {
font-size: 1.5rem;
}
}
/* Styles pour les logos dans la liste des marchands (TABLE) */
.merchant-logo-list {
width: 40px;
height: 40px;
min-width: 40px;
}
.merchant-logo-list img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
border: 2px solid #f0f0f0;
}
.merchant-logo-list-placeholder {
width: 40px;
height: 40px;
min-width: 40px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
border: 2px solid #f0f0f0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
/* Gardez vos styles existants pour le profil */
.merchant-logo-container {
width: 120px;
height: 120px;
flex-shrink: 0;
}
.merchant-logo-large {
width: 100%;
height: 100%;
object-fit: cover;
border: 3px solid rgba(255, 255, 255, 0.3);
}
.merchant-logo-large-placeholder {
width: 100%;
height: 100%;
border: 3px solid rgba(255, 255, 255, 0.3);
}
/* Responsive */
@media (max-width: 768px) {
.merchant-logo-list {
width: 32px;
height: 32px;
min-width: 32px;
}
.merchant-logo-list-placeholder {
width: 32px;
height: 32px;
min-width: 32px;
}
.merchant-logo-container {
width: 80px;
height: 80px;
}
}
/* ==================== STYLES DU LOGO ==================== */
/* Conteneur principal */
.logo-card {
position: relative;
width: 220px;
height: 220px;
transition: all 0.3s ease;
}
.logo-card:hover {
transform: translateY(-5px);
}
/* Conteneur d'affichage du logo */
.logo-display-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: 16px;
background: linear-gradient(145deg, #ffffff, #f0f0f0);
}
/* Logo marchand */
.merchant-logo {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.5s ease;
opacity: 0;
}
.merchant-logo.logo-loaded {
opacity: 1;
}
.logo-display-container:hover .merchant-logo {
transform: scale(1.05);
}
/* Placeholder par défaut */
.default-logo-placeholder {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.logo-initials {
font-size: 4rem;
font-weight: bold;
text-transform: uppercase;
}
/* Overlay au hover */
.logo-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
}
.logo-display-container:hover .logo-overlay {
opacity: 1;
}
.overlay-content {
text-align: center;
color: white;
}
.overlay-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.overlay-text {
font-size: 0.8rem;
opacity: 0.9;
}
/* Badge de statut */
.status-badge {
position: absolute;
top: 10px;
right: 10px;
padding: 0.25rem 0.5rem;
font-size: 0.7rem;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
/* Loader */
.logo-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
}
/* Informations sous le logo */
.logo-info {
max-width: 220px;
}
.merchant-name {
font-size: 1.1rem;
color: #333;
}
/* Actions */
.logo-actions .btn {
padding: 0.25rem 0.5rem;
border-radius: 20px;
}
.logo-actions .btn:hover {
transform: scale(1.1);
}
/* Animation de chargement */
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.merchant-logo {
animation: fadeIn 0.5s ease forwards;
}
/* États */
.logo-loading .logo-display-container {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}

View File

@ -1,11 +0,0 @@
{
"folders": [
{
"path": "../../../../../dcb-user-service"
},
{
"path": "../../../.."
}
],
"settings": {}
}

View File

@ -1,7 +1,7 @@
import { Injectable, inject, EventEmitter } from '@angular/core';
import { HttpClient, HttpErrorResponse, HttpHeaders } from '@angular/common/http';
import { environment } from '@environments/environment';
import { BehaviorSubject, Observable, throwError, tap, catchError, finalize, of, filter, take } from 'rxjs';
import { BehaviorSubject, Observable, throwError, tap, catchError, finalize, of, filter, take, map } from 'rxjs';
import { firstValueFrom } from 'rxjs';
import {
@ -325,13 +325,12 @@ export class AuthService {
return this.http.get<any>(
`${environment.iamApiUrl}/auth/profile`
).pipe(
tap(apiResponse => {
// Déterminer le type d'utilisateur
map(apiResponse => {
const userType = this.determineUserType(apiResponse);
// Mapper vers le modèle User
const userProfile = this.mapToUserModel(apiResponse, userType);
this.userProfile$.next(userProfile);
return userProfile;
}),
catchError(error => {
console.error('❌ Erreur chargement profil:', error);

View File

@ -96,8 +96,7 @@ export class MenuService {
{ label: 'Configurations', isTitle: true },
{ label: 'Merchant Config', icon: 'lucideStore', url: '/merchant-config' },
{ label: 'Support & Profil', isTitle: true },
{ label: 'Support', icon: 'lucideLifeBuoy', url: '/support' },
{ label: 'Profil', isTitle: true },
{ label: 'Mon Profil', icon: 'lucideUser', url: '/profile' },
{ label: 'Informations', isTitle: true },
@ -111,8 +110,7 @@ export class MenuService {
return [
{ label: 'Welcome back!', isHeader: true },
{ label: 'Profile', icon: 'tablerUserCircle', url: '/profile' },
{ label: 'Account Settings', icon: 'tablerSettings2', url: '/settings' },
{ label: 'Support Center', icon: 'tablerHeadset', url: '/support' },
{ label: 'Aide', icon: 'tablerHeadset', url: '/help' },
{ isDivider: true },
{
label: 'Déconnexion',

View File

@ -0,0 +1,405 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpEventType, HttpResponse } from '@angular/common/http';
import { Observable, throwError, of, Subject } from 'rxjs';
import { map, catchError, tap, filter } from 'rxjs/operators';
import { environment } from '@environments/environment';
export interface ImageUploadResponse {
message : string;
success: boolean;
merchant: {
id: string,
name: string
};
data: {
fileName: string;
url: string;
publicUrl: string;
downloadUrl: string;
size: number;
contentType: string;
uploadedAt: Date;
};
}
export interface ImageValidationResult {
valid: boolean;
error?: string;
}
export interface UploadProgress {
loaded: number;
total: number;
percentage: number;
}
export interface LogoUrlResponse {
success: boolean;
data: {
fileName: string;
url: string;
merchantId: string;
merchantName: string;
};
}
export interface LogoUrlOptions {
signed?: boolean;
expirySeconds?: number;
}
@Injectable({
providedIn: 'root'
})
export class MinioService {
private http = inject(HttpClient);
private baseUrl = `${environment.configApiUrl}/images`;
// Sujet pour les événements de progression
private uploadProgressSubject = new Subject<UploadProgress>();
public uploadProgress$ = this.uploadProgressSubject.asObservable();
// Types MIME autorisés
private readonly ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/jpg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml'
];
// Taille maximale : 5MB
private readonly MAX_FILE_SIZE = 5 * 1024 * 1024;
/**
* Valide un fichier image
*/
validateImageFile(file: File): ImageValidationResult {
// Vérifier le type MIME
if (!this.ALLOWED_MIME_TYPES.includes(file.type)) {
return {
valid: false,
error: `Type de fichier non autorisé. Formats acceptés : JPG, PNG, GIF, WebP, SVG`
};
}
// Vérifier la taille
if (file.size > this.MAX_FILE_SIZE) {
const maxSizeMB = this.MAX_FILE_SIZE / (1024 * 1024);
return {
valid: false,
error: `Fichier trop volumineux. Taille maximum : ${maxSizeMB}MB`
};
}
return { valid: true };
}
/**
* MÉTHODE PRINCIPALE - Upload via le serveur
* préparation, upload et vérification
*/
uploadMerchantLogo( merchantId: number, merchantName: string, file: File): Observable<ImageUploadResponse> {
// Validation
const validation = this.validateImageFile(file);
if (!validation.valid) {
return throwError(() => new Error(validation.error));
}
const formData = new FormData();
formData.append('file', file);
// Ajouter des métadonnées optionnelles
if (merchantId) {
formData.append('merchantId', merchantId.toString());
}
if (merchantName) {
formData.append('merchantName', merchantName);
}
// Headers
const headers = new HttpHeaders({
'Accept': 'application/json'
});
return this.http.post<ImageUploadResponse>(
`${this.baseUrl}/merchants/${merchantId}/logos/upload`,
formData,
{
headers,
reportProgress: true,
observe: 'events'
}
).pipe(
filter(event => event.type === HttpEventType.Response),
// Extraire le body
map(event => event.body as ImageUploadResponse),
catchError(error => {
console.error('❌ Error uploading image:', error);
return throwError(() => new Error(
error.error?.message ||
error.error?.error ||
error.message ||
'Erreur lors de l\'upload du logo'
));
})
);
}
/**
* Récupère l'URL (presigned) d'un logo marchand
*/
getMerchantLogoUrl(
merchantId: string,
fileName: string,
options: LogoUrlOptions = {}
): Observable<LogoUrlResponse> {
if (!merchantId || !fileName || fileName.trim() === '') {
return throwError(() => new Error('Paramètres invalides'));
}
const params: any = {
fileName
};
if (options.signed) {
params.signed = 'true';
}
if (options.expirySeconds) {
params.expiry = options.expirySeconds.toString();
}
return this.http.get<LogoUrlResponse>(
`${this.baseUrl}/merchants/${merchantId}/logos/url`,
{ params }
).pipe(
map(response => {
if (response?.success && response.data?.url) {
return response;
}
throw new Error('Logo non trouvé');
}),
catchError(error => {
console.error('❌ Error getting logo URL:', error);
return throwError(() => new Error(
error.error?.message ||
error.message ||
'Logo non trouvé'
));
})
);
}
/**
* Récupère les informations complètes d'une image
*/
getImageInfo(fileName: string): Observable<ImageUploadResponse> {
if (!fileName || fileName.trim() === '') {
return throwError(() => new Error('Nom de fichier invalide'));
}
return this.http.get<{
success: boolean;
data?: ImageUploadResponse
}>(`${this.baseUrl}/info/${encodeURIComponent(fileName)}`).pipe(
map(response => {
if (response.success && response.data) {
return response.data;
}
throw new Error('Image not found or error in response');
}),
catchError(this.handleError('get image info'))
);
}
/**
* Supprime un logo
*/
deleteMerchantLogo(
merchantId: string,
fileName: string
): Observable<void> {
if (!merchantId || !fileName || fileName.trim() === '') {
return throwError(() => new Error('Paramètres invalides ou Nom de fichier invalide'));
}
const params: any = {
fileName
};
return this.http.delete<{
success: boolean;
message: string
}>(
`${this.baseUrl}/merchants/${merchantId}/logos/url`,
{ params }
).pipe(
map(response => {
if (response.success) {
return void 0;
}
throw new Error(response.message || 'Erreur lors de la suppression');
}),
catchError(error => {
console.error('❌ Error deleting logo:', error);
return throwError(() => new Error(
error.error?.message || 'Erreur lors de la suppression du logo'
));
})
);
}
/**
* Liste les images de l'utilisateur courant
*/
getMyImages(): Observable<ImageUploadResponse[]> {
return this.http.get<{
success: boolean;
count: number;
images: ImageUploadResponse[]
}>(`${this.baseUrl}/my-images`).pipe(
map(response => {
if (response.success) {
return response.images || [];
}
return [];
}),
catchError(error => {
console.error('❌ Error getting user images:', error);
return of([]);
})
);
}
/**
* Prévisualise une image avant upload
*/
previewImage(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
if (e.target?.result) {
resolve(e.target.result as string);
} else {
reject(new Error('Impossible de lire le fichier'));
}
};
reader.onerror = () => {
reject(new Error('Erreur lors de la lecture du fichier'));
};
reader.readAsDataURL(file);
});
}
/**
* Formate la taille d'un fichier
*/
formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**
* Vérifie si un nom de fichier est valide
*/
isValidFileName(fileName: string): boolean {
return typeof fileName === 'string' && fileName.trim().length > 0;
}
/**
* Extrait le nom de fichier depuis le chemin complet
*/
extractFileNameFromPath(filePath: string): string {
return filePath.split('/').pop() || filePath;
}
/**
* Crée une URL d'objet blob pour prévisualisation
*/
createObjectUrl(file: File): string {
return URL.createObjectURL(file);
}
/**
* Révoque une URL d'objet blob
*/
revokeObjectUrl(url: string): void {
URL.revokeObjectURL(url);
}
/**
* Réinitialise la progression
*/
resetUploadProgress(): void {
this.uploadProgressSubject.next({ loaded: 0, total: 0, percentage: 0 });
}
/**
* Gestionnaire d'erreurs générique
*/
private handleError(operation: string) {
return (error: any): Observable<never> => {
console.error(`❌ Error in ${operation}:`, error);
let errorMessage = 'Une erreur est survenue';
if (error.error instanceof ErrorEvent) {
// Erreur côté client
errorMessage = error.error.message;
} else {
// Erreur côté serveur
errorMessage = error.error?.message ||
error.error?.error ||
error.message ||
`Error ${error.status}: ${error.statusText}`;
}
return throwError(() => new Error(errorMessage));
};
}
/**
* Vérifie si le type MIME est une image
*/
isImageMimeType(mimeType: string): boolean {
return this.ALLOWED_MIME_TYPES.includes(mimeType);
}
/**
* Génère un nom de fichier sécurisé côté client (optionnel)
*/
generateSafeFileName(originalName: string, userId?: string): string {
const timestamp = Date.now();
const randomString = Math.random().toString(36).substring(2, 10);
const extension = originalName.includes('.')
? originalName.substring(originalName.lastIndexOf('.'))
: '';
const baseName = originalName.includes('.')
? originalName.substring(0, originalName.lastIndexOf('.'))
: originalName;
const safeBaseName = baseName
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9-]/g, '_')
.replace(/_+/g, '_')
.toLowerCase()
.substring(0, 100);
const prefix = userId ? `${userId}/` : '';
return `${prefix}${timestamp}_${randomString}_${safeBaseName}${extension}`;
}
}

View File

@ -15,14 +15,41 @@
<!-- États normal et erreur avec @if -->
@if (!isLoading) {
<div class="d-flex align-items-center">
<img
[src]="getUserAvatar()"
class="rounded-circle me-2"
width="36"
height="36"
alt="user-image"
(error)="onAvatarError($event)"
/>
@if (user){
@if (merchant){
@if (merchant.logo && merchant.logo.trim() !== '') {
<img
[src]="getMerchantLogoUrl(merchant.id, merchant.logo, merchant.name) | async"
[alt]="merchant.name + ' logo'"
class="rounded-circle me-2"
width="36"
height="36"
loading="lazy"
(error)="onLogoError($event, merchant.name)"
/>
} @else {
<img
[src]="getDefaultLogoUrl(merchant.name)"
[alt]="merchant.name + ' logo'"
class="rounded-circle me-2"
width="36"
height="36"
loading="lazy"
(error)="onDefaultLogoError($event)"
/>
}
}@else {
<img
[src]="getDefaultLogoUrl(user.username)"
[alt]="user.username + ' logo'"
class="rounded-circle me-2"
width="36"
height="36"
loading="lazy"
(error)="onDefaultLogoError($event)"
/>
}
}
<div>
<h5 class="my-0 fw-semibold">
{{ getDisplayName() || 'Utilisateur' }}

View File

@ -3,23 +3,45 @@ import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap';
import { userDropdownItems } from '@layouts/components/data';
import { AuthService } from '@/app/core/services/auth.service';
import { User, UserRole } from '@core/models/dcb-bo-hub-user.model';
import { Subject, takeUntil, distinctUntilChanged, filter, startWith } from 'rxjs';
import { Subject, takeUntil, distinctUntilChanged, filter, startWith, catchError, map, Observable, of, Subscription } from 'rxjs';
import { CommonModule } from '@angular/common';
import { Merchant } from '@core/models/merchant-config.model';
import { MinioService } from '@core/services/minio.service';
import { MerchantConfigService } from '@modules/merchant-config/merchant-config.service';
@Component({
selector: 'app-user-profile',
standalone: true,
imports: [NgbCollapseModule],
imports: [NgbCollapseModule,CommonModule],
templateUrl: './user-profile.component.html',
})
export class UserProfileComponent implements OnInit, OnDestroy {
private authService = inject(AuthService);
private cdr = inject(ChangeDetectorRef);
private merchantConfigService = inject(MerchantConfigService);
private subscription?: Subscription;
private minioService = inject(MinioService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
user: User | null = null;
// Cache des URLs de logos
private logoUrlCache = new Map<string, string>();
// Ajouter un cache pour les logos non trouvés
private logoErrorCache = new Set<string>();
// Cache
private merchantCache: { data: Merchant, timestamp: number } | null = null;
private readonly CACHE_TTL = 2 * 60 * 1000; // 2 minutes
// Permissions
currentUserRole: any = null;
isHubUser = false;
merchant: Merchant | null = null;
user: User | undefined;
merchanPartnerId: string | undefined
// États
isLoading = true;
hasError = false;
hasSuccess = '';
currentProfileLoaded = false;
ngOnInit(): void {
@ -45,9 +67,9 @@ export class UserProfileComponent implements OnInit, OnDestroy {
// Le profil sera chargé via la subscription
} else {
console.log('🔐 User not authenticated');
this.user = null;
this.user = undefined;
this.isLoading = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
}
}
@ -76,22 +98,31 @@ export class UserProfileComponent implements OnInit, OnDestroy {
if (profile) {
console.log('📥 User profile updated:', profile.username);
this.user = profile;
this.currentUserRole = this.extractUserRole(profile);
this.isHubUser = this.checkIfHubUser();
if (!this.isHubUser) {
this.merchanPartnerId = profile?.merchantPartnerId;
this.loadMerchantProfile()
}
this.currentProfileLoaded = true;
} else {
console.log('📭 User profile cleared');
this.user = null;
this.user = undefined;
this.currentProfileLoaded = false;
}
this.isLoading = false;
this.hasError = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error in profile subscription:', error);
this.hasError = true;
this.isLoading = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
}
});
}
@ -115,10 +146,10 @@ export class UserProfileComponent implements OnInit, OnDestroy {
} else {
// Si l'utilisateur s'est déconnecté
console.log('👋 User logged out');
this.user = null;
this.user = undefined;
this.currentProfileLoaded = false;
this.isLoading = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
}
}
});
@ -130,21 +161,20 @@ export class UserProfileComponent implements OnInit, OnDestroy {
loadUserProfile(): void {
this.isLoading = true;
this.hasError = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
this.authService.loadUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (profile) => {
// Note: le profil sera automatiquement mis à jour via la subscription getUserProfile()
this.isLoading = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Failed to load user profile:', error);
this.hasError = true;
this.isLoading = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
// Essayer de rafraîchir le token si erreur 401
if (error.status === 401) {
@ -162,6 +192,179 @@ export class UserProfileComponent implements OnInit, OnDestroy {
});
}
/**
* Charge le profil COMPLET du merchant
*/
loadMerchantProfile() {
if (this.shouldUseCache()) {
this.merchant = this.merchantCache!.data;
this.isLoading = false;
this.cdRef.detectChanges();
return;
}
this.isLoading = true;
this.hasError = false;
console.log("📥 Chargement du profil complet du merchant:", this.merchanPartnerId);
this.merchantConfigService.getMerchantById(Number(this.merchanPartnerId))
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchant) => {
this.merchant = merchant;
// Mise en cache
this.merchantCache = {
data: merchant,
timestamp: Date.now()
};
console.log("✅ Profil merchant chargé:", merchant);
this.isLoading = false;
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error loading merchant profile:', error);
this.hasError = true;
this.isLoading = false;
this.cdRef.detectChanges();
}
});
}
// ==================== AFFICHAGE DU LOGO ====================
/**
* Récupère l'URL du logo avec fallback automatique
*/
getMerchantLogoUrl(
merchanPartnerId: number | undefined,
logoFileName: string,
merchantName: string
): Observable<string> {
const newMerchantId = String(merchanPartnerId);
const cacheKey = `${merchanPartnerId}_${logoFileName}`;
// Vérifier si le logo est en cache d'erreur
if (this.logoErrorCache.has(cacheKey)) {
const defaultLogo = this.getDefaultLogoUrl(merchantName);
return of(defaultLogo);
}
// Vérifier le cache normal
if (this.logoUrlCache.has(cacheKey)) {
return of(this.logoUrlCache.get(cacheKey)!);
}
// Récupérer l'URL depuis l'API avec la nouvelle structure
return this.minioService.getMerchantLogoUrl(
newMerchantId,
logoFileName,
{ signed: true, expirySeconds: 3600 }
).pipe(
map(response => {
// Extraire l'URL de la réponse
const url = response.data.url ;
// Mettre en cache avec la clé composite
this.logoUrlCache.set(cacheKey, url);
return url;
}),
catchError(error => {
console.warn(`⚠️ Logo not found for merchant ${merchanPartnerId}: ${logoFileName}`, error);
// En cas d'erreur, ajouter au cache d'erreur
this.logoErrorCache.add(cacheKey);
// Générer un logo par défaut
const defaultLogo = this.getDefaultLogoUrl(merchantName);
// Mettre le logo par défaut dans le cache normal aussi
this.logoUrlCache.set(cacheKey, defaultLogo);
return of(defaultLogo);
})
);
}
/**
* Génère une URL de logo par défaut basée sur les initiales
*/
getDefaultLogoUrl(merchantName: string): string {
// Créer des initiales significatives
const initials = this.extractInitials(merchantName);
// Palette de couleurs agréables
const colors = [
'667eea', // Violet
'764ba2', // Violet foncé
'f56565', // Rouge
'4299e1', // Bleu
'48bb78', // Vert
'ed8936', // Orange
'FF6B6B', // Rouge clair
'4ECDC4', // Turquoise
'45B7D1', // Bleu clair
'96CEB4' // Vert menthe
];
const colorIndex = merchantName.length % colors.length;
const backgroundColor = colors[colorIndex];
// Taille fixe à 80px (l'API génère un carré de cette taille)
// L'image sera redimensionnée à 40px via CSS
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`;
}
/**
* Gère les erreurs de chargement des logos MinIO
*/
onLogoError(event: Event, merchantName: string): void {
const img = event.target as HTMLImageElement;
if (!img) return;
console.warn('Logo MinIO failed to load, using default for:', merchantName);
img.onerror = null;
img.src = this.getDefaultLogoUrl(merchantName);
}
/**
* Gère les erreurs de chargement des logos par défaut
*/
onDefaultLogoError(event: Event | string): void {
if (!(event instanceof Event)) {
console.error('Default logo error (non-event):', event);
return;
}
const img = event.target as HTMLImageElement | null;
if (!img) return;
console.error('Default logo also failed to load, using fallback SVG');
// SVG local
img.onerror = null; // éviter boucle infinie
img.src = 'assets/images/default-merchant-logo.svg';
// Dernier recours
img.onerror = (e) => {
if (!(e instanceof Event)) return;
const fallbackImg = e.target as HTMLImageElement | null;
if (!fallbackImg) return;
fallbackImg.onerror = null;
fallbackImg.src = this.generateFallbackDataUrl();
};
}
/**
* Méthode pour réessayer le chargement en cas d'erreur
*/
@ -234,19 +437,91 @@ export class UserProfileComponent implements OnInit, OnDestroy {
return roleClassMap[this.user.role] || 'badge bg-secondary';
}
/**
* Obtient l'URL de l'avatar de l'utilisateur
*/
getUserAvatar(): string {
return `assets/images/users/user-2.jpg`;
private extractUserRole(user: any): any {
const userRoles = this.authService.getCurrentUserRoles();
return userRoles && userRoles.length > 0 ? userRoles[0] : null;
}
private checkIfHubUser(): boolean {
if (!this.currentUserRole) return false;
const hubRoles = [
UserRole.DCB_ADMIN,
UserRole.DCB_SUPPORT
];
return hubRoles.includes(this.currentUserRole);
}
private shouldUseCache(): boolean {
if (!this.merchantCache) return false;
const cacheAge = Date.now() - this.merchantCache.timestamp;
return cacheAge < this.CACHE_TTL && this.merchantCache.data !== null;
}
private clearCache(): void {
this.merchantCache = null;
}
/**
* Gère les erreurs de chargement d'avatar
* Extrait les initiales de manière intelligente
*/
onAvatarError(event: Event): void {
const img = event.target as HTMLImageElement;
img.src = 'assets/images/users/user-2.jpg';
img.onerror = null;
private extractInitials(name: string): string {
if (!name || name.trim() === '') {
return '??';
}
// Nettoyer le nom
const cleanedName = name.trim().toUpperCase();
// Extraire les mots
const words = cleanedName.split(/\s+/);
// Si un seul mot, prendre les deux premières lettres
if (words.length === 1) {
return words[0].substring(0, 2) || '??';
}
// Prendre la première lettre des deux premiers mots
const initials = words
.slice(0, 2) // Prendre les 2 premiers mots
.map(word => word[0] || '')
.join('');
return initials || name.substring(0, 2).toUpperCase() || '??';
}
/**
* Génère un fallback SVG en data URL
*/
private generateFallbackDataUrl(): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect width="40" height="40" fill="#667eea" rx="20"/>
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
}
// ==================== GESTION DES ERREURS ====================
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 400) {
return 'Données invalides. Vérifiez les informations saisies.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions nécessaires pour cette action';
}
if (error.status === 404) {
return 'Utilisateur non trouvé';
}
if (error.status === 409) {
return 'Cet email est déjà utilisé par un autre utilisateur';
}
return 'Une erreur est survenue. Veuillez réessayer.';
}
}

View File

@ -4,12 +4,41 @@
ngbDropdownToggle
class="topbar-link dropdown-toggle drop-arrow-none px-2"
>
<img
src="assets/images/users/user-2.jpg"
width="32"
class="rounded-circle d-flex"
alt="user-image"
/>
@if (user){
@if (merchant){
@if (merchant.logo && merchant.logo.trim() !== '') {
<img
[src]="getMerchantLogoUrl(merchant.id, merchant.logo, merchant.name) | async"
[alt]="merchant.name + ' logo'"
class="rounded-circle me-2"
width="36"
height="36"
loading="lazy"
(error)="onLogoError($event, merchant.name)"
/>
} @else {
<img
[src]="getDefaultLogoUrl(merchant.name)"
[alt]="merchant.name + ' logo'"
class="rounded-circle me-2"
width="36"
height="36"
loading="lazy"
(error)="onDefaultLogoError($event)"
/>
}
}@else {
<img
[src]="getDefaultLogoUrl(user.username)"
[alt]="user.username + ' logo'"
class="rounded-circle me-2"
width="36"
height="36"
loading="lazy"
(error)="onDefaultLogoError($event)"
/>
}
}
</button>
<div ngbDropdownMenu class="dropdown-menu dropdown-menu-end">
@for (item of menuItems; track $index; let i = $index) {

View File

@ -1,4 +1,4 @@
import { Component, inject, OnInit, OnDestroy } from '@angular/core'
import { Component, inject, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'
import { AuthService } from '@core/services/auth.service'
import { MenuService } from '@core/services/menu.service'
import {
@ -9,7 +9,12 @@ import {
import { RouterLink } from '@angular/router'
import { NgIcon } from '@ng-icons/core'
import { UserDropdownItemType } from '@/app/types/layout'
import { Subscription } from 'rxjs'
import { catchError, map, Observable, of, Subject, Subscription, takeUntil } from 'rxjs'
import { MinioService } from '@core/services/minio.service'
import { Merchant } from '@core/models/merchant-config.model'
import { MerchantConfigService } from '@modules/merchant-config/merchant-config.service'
import { User, UserRole } from '@core/models/dcb-bo-hub-user.model'
import { CommonModule } from '@angular/common'
@Component({
selector: 'app-user-profile-topbar',
@ -19,17 +24,46 @@ import { Subscription } from 'rxjs'
NgbDropdownToggle,
RouterLink,
NgIcon,
CommonModule,
],
templateUrl: './user-profile.html',
})
export class UserProfile implements OnInit, OnDestroy {
private authService = inject(AuthService)
private menuService = inject(MenuService)
private subscription?: Subscription
private authService = inject(AuthService);
private merchantConfigService = inject(MerchantConfigService);
private menuService = inject(MenuService);
private subscription?: Subscription;
private minioService = inject(MinioService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
// Cache des URLs de logos
private logoUrlCache = new Map<string, string>();
// Ajouter un cache pour les logos non trouvés
private logoErrorCache = new Set<string>();
// Cache
private merchantCache: { data: Merchant, timestamp: number } | null = null;
private readonly CACHE_TTL = 2 * 60 * 1000; // 2 minutes
// Permissions
currentUserRole: any = null;
isHubUser = false;
merchant: Merchant | null = null;
user: User | undefined;
// États
loading = false;
error = '';
success = '';
menuItems: UserDropdownItemType[] = []
merchanPartnerId: string | undefined
ngOnInit() {
this.loadUserProfile()
this.loadDropdownItems()
this.subscription = this.authService.onAuthState().subscribe(() => {
@ -37,11 +71,305 @@ export class UserProfile implements OnInit, OnDestroy {
})
}
ngOnDestroy() {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
this.subscription?.unsubscribe()
}
// ==================== CHARGEMENT DES DONNÉES ====================
loadUserProfile() {
this.loading = true;
this.error = '';
this.authService.loadUserProfile()
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (profile) => {
this.user = profile;
console.log("Profile User : " + profile?.role);
this.currentUserRole = this.extractUserRole(profile);
this.isHubUser = this.checkIfHubUser();
if (!this.isHubUser) {
this.merchanPartnerId = profile.merchantPartnerId;
this.loadMerchantProfile()
}
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
this.error = 'Erreur lors du chargement de votre profil';
this.loading = false;
this.cdRef.detectChanges();
console.error('Error loading user profile:', error);
}
});
}
/**
* Charge le profil COMPLET du merchant
*/
loadMerchantProfile() {
if (this.shouldUseCache()) {
this.merchant = this.merchantCache!.data;
this.loading = false;
this.cdRef.detectChanges();
return;
}
this.loading = true;
this.error = '';
console.log("📥 Chargement du profil complet du merchant:", this.merchanPartnerId);
this.merchantConfigService.getMerchantById(Number(this.merchanPartnerId))
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchant) => {
this.merchant = merchant;
// Mise en cache
this.merchantCache = {
data: merchant,
timestamp: Date.now()
};
console.log("✅ Profil merchant chargé:", merchant);
this.loading = false;
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error loading merchant profile:', error);
this.error = this.getErrorMessage(error);
this.loading = false;
this.cdRef.detectChanges();
}
});
}
// ==================== AFFICHAGE DU LOGO ====================
/**
* Récupère l'URL du logo avec fallback automatique
*/
getMerchantLogoUrl(
merchanPartnerId: number | undefined,
logoFileName: string,
merchantName: string
): Observable<string> {
const newMerchantId = String(merchanPartnerId);
const cacheKey = `${merchanPartnerId}_${logoFileName}`;
// Vérifier si le logo est en cache d'erreur
if (this.logoErrorCache.has(cacheKey)) {
const defaultLogo = this.getDefaultLogoUrl(merchantName);
return of(defaultLogo);
}
// Vérifier le cache normal
if (this.logoUrlCache.has(cacheKey)) {
return of(this.logoUrlCache.get(cacheKey)!);
}
// Récupérer l'URL depuis l'API avec la nouvelle structure
return this.minioService.getMerchantLogoUrl(
newMerchantId,
logoFileName,
{ signed: true, expirySeconds: 3600 }
).pipe(
map(response => {
// Extraire l'URL de la réponse
const url = response.data.url ;
// Mettre en cache avec la clé composite
this.logoUrlCache.set(cacheKey, url);
return url;
}),
catchError(error => {
console.warn(`⚠️ Logo not found for merchant ${merchanPartnerId}: ${logoFileName}`, error);
// En cas d'erreur, ajouter au cache d'erreur
this.logoErrorCache.add(cacheKey);
// Générer un logo par défaut
const defaultLogo = this.getDefaultLogoUrl(merchantName);
// Mettre le logo par défaut dans le cache normal aussi
this.logoUrlCache.set(cacheKey, defaultLogo);
return of(defaultLogo);
})
);
}
/**
* Génère une URL de logo par défaut basée sur les initiales
*/
getDefaultLogoUrl(merchantName: string): string {
// Créer des initiales significatives
const initials = this.extractInitials(merchantName);
// Palette de couleurs agréables
const colors = [
'667eea', // Violet
'764ba2', // Violet foncé
'f56565', // Rouge
'4299e1', // Bleu
'48bb78', // Vert
'ed8936', // Orange
'FF6B6B', // Rouge clair
'4ECDC4', // Turquoise
'45B7D1', // Bleu clair
'96CEB4' // Vert menthe
];
const colorIndex = merchantName.length % colors.length;
const backgroundColor = colors[colorIndex];
// Taille fixe à 80px (l'API génère un carré de cette taille)
// L'image sera redimensionnée à 40px via CSS
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`;
}
/**
* Gère les erreurs de chargement des logos MinIO
*/
onLogoError(event: Event, merchantName: string): void {
const img = event.target as HTMLImageElement;
if (!img) return;
console.warn('Logo MinIO failed to load, using default for:', merchantName);
img.onerror = null;
img.src = this.getDefaultLogoUrl(merchantName);
}
/**
* Gère les erreurs de chargement des logos par défaut
*/
onDefaultLogoError(event: Event | string): void {
if (!(event instanceof Event)) {
console.error('Default logo error (non-event):', event);
return;
}
const img = event.target as HTMLImageElement | null;
if (!img) return;
console.error('Default logo also failed to load, using fallback SVG');
// SVG local
img.onerror = null; // éviter boucle infinie
img.src = 'assets/images/default-merchant-logo.svg';
// Dernier recours
img.onerror = (e) => {
if (!(e instanceof Event)) return;
const fallbackImg = e.target as HTMLImageElement | null;
if (!fallbackImg) return;
fallbackImg.onerror = null;
fallbackImg.src = this.generateFallbackDataUrl();
};
}
private extractUserRole(user: any): any {
const userRoles = this.authService.getCurrentUserRoles();
return userRoles && userRoles.length > 0 ? userRoles[0] : null;
}
private checkIfHubUser(): boolean {
if (!this.currentUserRole) return false;
const hubRoles = [
UserRole.DCB_ADMIN,
UserRole.DCB_SUPPORT
];
return hubRoles.includes(this.currentUserRole);
}
private loadDropdownItems() {
this.menuItems = this.menuService.getUserDropdownItems()
}
private shouldUseCache(): boolean {
if (!this.merchantCache) return false;
const cacheAge = Date.now() - this.merchantCache.timestamp;
return cacheAge < this.CACHE_TTL && this.merchantCache.data !== null;
}
private clearCache(): void {
this.merchantCache = null;
}
/**
* Extrait les initiales de manière intelligente
*/
private extractInitials(name: string): string {
if (!name || name.trim() === '') {
return '??';
}
// Nettoyer le nom
const cleanedName = name.trim().toUpperCase();
// Extraire les mots
const words = cleanedName.split(/\s+/);
// Si un seul mot, prendre les deux premières lettres
if (words.length === 1) {
return words[0].substring(0, 2) || '??';
}
// Prendre la première lettre des deux premiers mots
const initials = words
.slice(0, 2) // Prendre les 2 premiers mots
.map(word => word[0] || '')
.join('');
return initials || name.substring(0, 2).toUpperCase() || '??';
}
/**
* Génère un fallback SVG en data URL
*/
private generateFallbackDataUrl(): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect width="40" height="40" fill="#667eea" rx="20"/>
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
}
// ==================== GESTION DES ERREURS ====================
private getErrorMessage(error: any): string {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 400) {
return 'Données invalides. Vérifiez les informations saisies.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions nécessaires pour cette action';
}
if (error.status === 404) {
return 'Utilisateur non trouvé';
}
if (error.status === 409) {
return 'Cet email est déjà utilisé par un autre utilisateur';
}
return 'Une erreur est survenue. Veuillez réessayer.';
}
}

View File

@ -307,7 +307,7 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
constructor(
private accessService: DashboardAccessService,
private cdr: ChangeDetectorRef
private cdRef: ChangeDetectorRef
) {
Chart.register(...registerables);
}
@ -326,12 +326,14 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
console.log('✅ Dashboard: waitForReady() a émis - Initialisation...');
this.dashboardInitialized = true;
this.initializeDashboard();
this.cdRef.detectChanges();
},
error: (err) => {
console.error('❌ Dashboard: Erreur dans waitForReady():', err);
// Gérer l'erreur - peut-être rediriger vers une page d'erreur
this.addAlert('danger', 'Erreur d\'initialisation',
'Impossible de charger les informations d\'accès', 'Maintenant');
this.cdRef.detectChanges();
}
})
);
@ -413,7 +415,7 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
this.accessService.getAvailableMerchants().subscribe({
next: (merchants) => {
this.allowedMerchants = merchants;
this.cdr.detectChanges();
this.cdRef.detectChanges();
},
error: (err) => {
console.error('Erreur lors du chargement des merchants:', err);
@ -445,14 +447,14 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
console.log('Données globales chargées avec succès');
this.loading.globalData = false;
this.calculateStats();
this.cdr.detectChanges();
this.cdRef.detectChanges();
setTimeout(() => this.updateAllCharts(), 100);
},
error: (err) => {
console.error('Erreur lors du chargement des données globales:', err);
this.loading.globalData = false;
this.addAlert('danger', 'Erreur de chargement', 'Impossible de charger les données globales', 'Maintenant');
this.cdr.detectChanges();
this.cdRef.detectChanges();
}
})
);
@ -485,7 +487,7 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
console.log(`Données du merchant ${merchantId} chargées avec succès`);
this.loading.merchantData = false;
this.calculateStats();
this.cdr.detectChanges();
this.cdRef.detectChanges();
setTimeout(() => this.updateAllCharts(), 100);
},
error: (err) => {
@ -493,7 +495,7 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
this.loading.merchantData = false;
this.addAlert('danger', 'Erreur de chargement',
`Impossible de charger les données du merchant ${merchantId}`, 'Maintenant');
this.cdr.detectChanges();
this.cdRef.detectChanges();
}
})
);
@ -730,12 +732,12 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
(ctx as any).chart = newChart;
this.loading.chart = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
} catch (error) {
console.error('Erreur lors de la création du graphique principal:', error);
this.loading.chart = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
}
}
@ -1109,13 +1111,13 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
this.updateOverallHealth();
this.generateHealthAlerts();
this.loading.healthCheck = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
}),
catchError(err => {
console.error('Erreur lors du health check:', err);
this.addAlert('danger', 'Erreur de vérification', 'Impossible de vérifier la santé des services', 'Maintenant');
this.loading.healthCheck = false;
this.cdr.detectChanges();
this.cdRef.detectChanges();
return of(null);
})
).subscribe()
@ -1274,7 +1276,7 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
this.metricDropdown.close();
}
this.cdr.detectChanges();
this.cdRef.detectChanges();
setTimeout(() => {
this.updateMainChart();
@ -1284,7 +1286,7 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
changePeriod(period: ReportPeriod): void {
this.dataSelection.period = period;
this.cdr.detectChanges();
this.cdRef.detectChanges();
setTimeout(() => {
this.updateMainChart();
@ -1294,7 +1296,7 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
changeChartType(type: ChartType): void {
this.dataSelection.chartType = type;
this.cdr.detectChanges();
this.cdRef.detectChanges();
setTimeout(() => {
this.updateMainChart();
@ -1302,7 +1304,7 @@ export class DcbReportingDashboard implements OnInit, OnDestroy, AfterViewInit {
}
refreshChartData(): void {
this.cdr.detectChanges();
this.cdRef.detectChanges();
setTimeout(() => {
this.updateAllCharts();
}, 50);

View File

@ -68,13 +68,26 @@ export interface ReportParams {
endDate?: string;
merchantPartnerId?: number;
}
export interface HealthCheckStatus {
service: string;
url: string;
status: 'UP' | 'DOWN';
statusCode: number;
checkedAt: string;
responseTime: string;
uptime?: number;
note?: string;
error?: string;
}
export interface HealthCheckResponse {
summary: {
total: number;
up: number;
down: number;
timestamp: string;
};
details: HealthCheckStatus[];
}
// ChartDataNormalized : normalisation des données pour tous types de chart

View File

@ -150,7 +150,7 @@ export class DashboardAccessService {
if (access.isHubUser) {
return this.merchantService.getAllMerchants().pipe(
map(merchants => {
const available: AllowedMerchant[] = merchants.map(m => ({
const available: AllowedMerchant[] = merchants.items.map(m => ({
id: m.id,
name: m.name
}));

View File

@ -8,6 +8,7 @@ import {
SubscriptionReport,
SyncResponse,
HealthCheckStatus,
HealthCheckResponse,
ChartDataNormalized
} from '../models/dcb-reporting.models';
import { environment } from '@environments/environment';
@ -282,7 +283,7 @@ export class ReportService {
}
// ---------------------
// Health checks (rest of the code remains the same)
// Health checks
// ---------------------
private checkApiAvailability(
@ -316,6 +317,8 @@ export class ReportService {
timeout(this.DEFAULT_TIMEOUT),
map((resp: HttpResponse<any>) => {
const finalResponseTime = Date.now() - startTime;
const body: any = resp.body;
return {
service,
url,
@ -323,6 +326,7 @@ export class ReportService {
statusCode: resp.status,
checkedAt: new Date().toISOString(),
responseTime: `${finalResponseTime}ms`,
uptime: body?.uptime,
note: 'Used GET fallback'
};
}),
@ -397,16 +401,17 @@ export class ReportService {
/**
* Health check global de toutes les APIs
* Scanne chaque URL d'API directement
*/
*/
private buildHealthUrl(baseUrl: string): string {
return `${baseUrl.replace(/\/$/, '')}/health`;
}
globalHealthCheck(): Observable<HealthCheckStatus[]> {
const healthChecks: Observable<HealthCheckStatus>[] = [];
// Vérifiez chaque service avec sa racine
Object.entries(this.apiEndpoints).forEach(([service, url]) => {
healthChecks.push(this.checkApiAvailability(service, url));
});
return forkJoin(healthChecks);
return forkJoin(
Object.entries(this.apiEndpoints).map(([service, url]) =>
this.checkApiAvailability(service, this.buildHealthUrl(url))
)
);
}
/**
@ -438,10 +443,7 @@ export class ReportService {
/**
* Health check détaillé avec métriques
*/
detailedHealthCheck(): Observable<{
summary: { total: number; up: number; down: number; timestamp: string };
details: HealthCheckStatus[];
}> {
detailedHealthCheck(): Observable<HealthCheckResponse> {
return this.globalHealthCheck().pipe(
map(results => {
const adjustedResults = results.map(result => ({
@ -462,7 +464,24 @@ export class ReportService {
details: adjustedResults
};
}),
catchError(err => this.handleError(err))
catchError(err => this.handleHealthError(err))
);
}
/**
* Gestion des erreurs
*/
private handleHealthError(error: any): Observable<HealthCheckResponse> {
console.error('Health check error:', error);
return of({
summary: {
total: 0,
up: 0,
down: 0,
timestamp: new Date().toISOString()
},
details: []
});
}
}

View File

@ -8,7 +8,6 @@ import { Subject, takeUntil } from 'rxjs';
import { HubUsersService } from './hub-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { MerchantSyncService } from './merchant-sync-orchestrator.service';
import { PageTitle } from '@app/components/page-title/page-title';
import { HubUsersList } from './hub-users-list/hub-users-list';
import { HubUserProfile } from './hub-users-profile/hub-users-profile';
@ -39,7 +38,6 @@ export class HubUsersManagement implements OnInit, OnDestroy {
private modalService = inject(NgbModal);
private authService = inject(AuthService);
private hubUsersService = inject(HubUsersService);
private merchantSyncService = inject(MerchantSyncService);
protected roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
@ -476,45 +474,6 @@ export class HubUsersManagement implements OnInit, OnDestroy {
});
}
/**
* Crée un marchand dans MerchantConfig (séparément de l'utilisateur)
*/
createMerchant() {
// Validation du formulaire marchand
const merchantValidation = this.validateMerchantForm();
if (!merchantValidation.isValid) {
this.createUserError = merchantValidation.error!;
console.error('❌ Merchant form validation failed:', merchantValidation.error);
return;
}
this.creatingMerchant = true;
console.log('📤 Creating merchant in MerchantConfig...');
this.merchantSyncService.createMerchantInConfigOnly(this.newMerchant)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchantConfig) => {
console.log('✅ Merchant created in MerchantConfig:', merchantConfig);
this.creatingMerchant = false;
// Optionnel: proposer d'associer un utilisateur au marchand créé
console.log(`✅ Merchant ID: ${merchantConfig.id} - Name: ${merchantConfig.name}`);
this.modalService.dismissAll();
this.resetMerchantForm();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error creating merchant in MerchantConfig:', error);
this.creatingMerchant = false;
this.createUserError = this.getMerchantErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
/**
* Vérifie si le rôle est un rôle partenaire
*/

View File

@ -1,507 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { Observable, forkJoin, map, switchMap, catchError, of, throwError } from 'rxjs';
import {
CreateUserDto,
User,
UserType,
UserRole,
} from '@core/models/dcb-bo-hub-user.model';
import { MerchantUsersService} from '@modules/hub-users-management/merchant-users.service';
import { MerchantConfigService } from '@modules/merchant-config/merchant-config.service';
import {
CreateMerchantDto,
UpdateMerchantDto,
Merchant,
MerchantUser,
AddUserToMerchantDto,
UpdateUserRoleDto
} from '@core/models/merchant-config.model';
export interface MerchantSyncResult {
merchantConfig: Merchant;
keycloakUser?: User; // Utilisateur associé (optionnel)
}
export interface MerchantUserSyncResult {
keycloakUser: User;
merchantConfigUser?: MerchantUser;
}
export interface MerchantSyncStatus {
existsInKeycloak: boolean;
existsInMerchantConfig: boolean;
usersSynced: boolean;
syncedUserCount: number;
totalUserCount: number;
}
export interface UserMerchantAssociation {
userId: string;
merchantConfigId: string;
role: UserRole; // Rôle dans MerchantConfig
}
@Injectable({ providedIn: 'root' })
export class MerchantSyncService {
private merchantUsersService = inject(MerchantUsersService);
private merchantConfigService = inject(MerchantConfigService);
// ==================== CONSTANTES ====================
private readonly KEYCLOAK_MERCHANT_ROLES = [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
];
private readonly MERCHANT_CONFIG_ROLES = [
UserRole.MERCHANT_CONFIG_ADMIN,
UserRole.MERCHANT_CONFIG_MANAGER,
UserRole.MERCHANT_CONFIG_TECHNICAL,
UserRole.MERCHANT_CONFIG_VIEWER
];
private readonly ROLE_MAPPING: Map<UserRole, UserRole> = new Map([
// Keycloak -> Merchant Config
[UserRole.DCB_PARTNER_ADMIN, UserRole.MERCHANT_CONFIG_ADMIN],
[UserRole.DCB_PARTNER_MANAGER, UserRole.MERCHANT_CONFIG_MANAGER],
[UserRole.DCB_PARTNER_SUPPORT, UserRole.MERCHANT_CONFIG_VIEWER],
// Merchant Config -> Keycloak
[UserRole.MERCHANT_CONFIG_ADMIN, UserRole.DCB_PARTNER_ADMIN],
[UserRole.MERCHANT_CONFIG_MANAGER, UserRole.DCB_PARTNER_MANAGER],
[UserRole.MERCHANT_CONFIG_TECHNICAL, UserRole.DCB_PARTNER_SUPPORT],
[UserRole.MERCHANT_CONFIG_VIEWER, UserRole.DCB_PARTNER_SUPPORT]
]);
// ==================== MÉTHODES DE BASE ====================
/**
* CREATE - Créer un merchant uniquement dans MerchantConfig
*/
createMerchantInConfigOnly(merchantData: CreateMerchantDto): Observable<Merchant> {
console.log('📝 CREATE Merchant dans MerchantConfig seulement...');
return this.merchantConfigService.createMerchant(merchantData).pipe(
map(merchant => {
console.log('✅ Merchant créé dans MerchantConfig:', merchant);
return merchant;
}),
catchError(error => {
console.error('❌ Échec création merchant dans MerchantConfig:', error);
return throwError(() => error);
})
);
}
/**
* CREATE - Créer un utilisateur dans Keycloak (sans association)
*/
createKeycloakUser(
userData: {
username: string;
email: string;
password: string;
firstName: string;
lastName: string;
role: UserRole;
enabled?: boolean;
emailVerified?: boolean;
}
): Observable<User> {
console.log('📝 CREATE User dans Keycloak...');
// Déterminer le type d'utilisateur selon le rôle
const userType = this.isMerchantRole(userData.role)
? UserType.MERCHANT_PARTNER
: UserType.HUB;
const keycloakUserDto: CreateUserDto = {
username: userData.username,
email: userData.email,
firstName: userData.firstName,
lastName: userData.lastName,
password: userData.password,
userType: userType,
role: userData.role,
enabled: userData.enabled ?? true,
emailVerified: userData.emailVerified ?? false
};
// Sélectionner le service approprié selon le type d'utilisateur
const createService = this.merchantUsersService.createMerchantUser(keycloakUserDto);
return createService.pipe(
map(user => {
console.log('✅ Utilisateur créé dans Keycloak:', user);
return user;
}),
catchError(error => {
console.error('❌ Échec création utilisateur dans Keycloak:', error);
return throwError(() => error);
})
);
}
/**
* READ - Récupérer un merchant depuis MerchantConfig seulement
*/
getMerchantFromConfigOnly(merchantConfigId: string): Observable<Merchant> {
console.log('🔍 READ Merchant depuis MerchantConfig seulement...');
return this.merchantConfigService.getMerchantById(Number(merchantConfigId)).pipe(
map(merchant => {
console.log('✅ Merchant récupéré depuis MerchantConfig:', merchant);
return merchant;
}),
catchError(error => {
console.error('❌ Erreur récupération merchant depuis MerchantConfig:', error);
return throwError(() => error);
})
);
}
/**
* READ - Récupérer tous les merchants depuis MerchantConfig
*/
getAllMerchantsFromConfig(): Observable<Merchant[]> {
console.log('🔍 READ All Merchants depuis MerchantConfig...');
return this.merchantConfigService.getAllMerchants().pipe(
map(merchants => {
console.log(`${merchants.length} merchants récupérés depuis MerchantConfig`);
return merchants;
}),
catchError(error => {
console.error('❌ Erreur récupération merchants depuis MerchantConfig:', error);
return throwError(() => error);
})
);
}
// ==================== ASSOCIATION UTILISATEUR-MERCHANT ====================
/**
* Associer un utilisateur existant à un marchand
*/
associateUserToMerchant(
association: UserMerchantAssociation
): Observable<MerchantUserSyncResult> {
console.log('🔗 ASSOCIATE User to Merchant...');
return forkJoin({
user: this.getUserById(association.userId),
merchant: this.merchantConfigService.getMerchantById(Number(association.merchantConfigId))
}).pipe(
switchMap(({ user, merchant }) => {
console.log(`🔗 Associating user ${user.username} to merchant ${merchant.name}`);
// 2. Ajouter l'utilisateur à MerchantConfig
const merchantConfigUserDto: AddUserToMerchantDto = {
userId: user.id,
role: association.role,
merchantPartnerId: Number(association.merchantConfigId),
};
return this.merchantConfigService.addUserToMerchant(merchantConfigUserDto).pipe(
map(merchantConfigUser => {
console.log('✅ User added to Merchant Config:', merchantConfigUser);
return {
keycloakUser: user,
merchantConfigUser
};
}),
catchError(error => {
console.error('❌ Error adding user to MerchantConfig:', error);
return throwError(() => error);
})
);
}),
catchError(error => {
console.error('❌ Error in association process:', error);
return throwError(() => error);
})
);
}
/**
* Dissocier un utilisateur d'un marchand
*/
dissociateUserFromMerchant(
userId: string,
merchantConfigId: string
): Observable<{ success: boolean; message: string }> {
console.log('🔗 DISSOCIATE User from Merchant...');
return forkJoin({
// Retirer l'utilisateur de MerchantConfig
merchantConfigRemoval: this.merchantConfigService.removeUserFromMerchant(
Number(merchantConfigId),
userId
).pipe(catchError(() => of({ ignored: true })))
}).pipe(
map(() => ({
success: true,
message: 'Utilisateur dissocié du marchand avec succès'
})),
catchError(async (error) => ({
success: false,
message: `Erreur lors de la dissociation: ${error.message}`
}))
);
}
/**
* Récupérer les utilisateurs associés à un marchand
*/
getUsersByMerchant(merchantConfigId: string): Observable<User[]> {
console.log('🔍 READ Users by Merchant...');
// Récupérer les utilisateurs de MerchantConfig
return this.merchantConfigService.getMerchantUsers(Number(merchantConfigId)).pipe(
switchMap(merchantConfigUsers => {
if (merchantConfigUsers.total === 0) {
return of([]);
}
// Récupérer les détails de chaque utilisateur depuis Keycloak
const userObservables = merchantConfigUsers.items.map((mcUser: { userId: string; }) =>
this.getUserById(mcUser.userId).pipe(
catchError(() => of(null)) // Ignorer les utilisateurs non trouvés
)
);
return forkJoin(userObservables).pipe(
map(users => users.filter((user): user is User => user !== null))
);
}),
map(users => {
console.log(`${users.length} utilisateurs trouvés pour merchant ${merchantConfigId}`);
return users;
}),
catchError(error => {
console.error('❌ Erreur récupération utilisateurs:', error);
return throwError(() => error);
})
);
}
// ==================== MÉTHODES DE GESTION ====================
/**
* UPDATE - Mettre à jour un merchant dans MerchantConfig seulement
*/
updateMerchantInConfigOnly(
merchantConfigId: string,
updates: UpdateMerchantDto
): Observable<Merchant> {
console.log('✏️ UPDATE Merchant dans MerchantConfig seulement...');
return this.merchantConfigService.updateMerchant(
Number(merchantConfigId),
updates
).pipe(
map(merchant => {
console.log('✅ Merchant mis à jour dans MerchantConfig:', merchant);
return merchant;
}),
catchError(error => {
console.error('❌ Erreur mise à jour merchant dans MerchantConfig:', error);
return throwError(() => error);
})
);
}
/**
* UPDATE - Mettre à jour un utilisateur dans Keycloak
*/
updateKeycloakUserRole(
keycloakUserId: string,
newRole: UserRole
): Observable<UpdateUserRoleDto> {
console.log('✏️ UPDATE User dans Keycloak...');
return this.getUserById(keycloakUserId).pipe(
switchMap(user => {
const updateService = this.merchantUsersService.updateMerchantUserRole(keycloakUserId, newRole);
return updateService.pipe(
map(updatedUser => {
console.log('✅ Utilisateur mis à jour dans Keycloak:', updatedUser);
return updatedUser;
})
);
}),
catchError(error => {
console.error('❌ Erreur mise à jour utilisateur dans Keycloak:', error);
return throwError(() => error);
})
);
}
/**
* UPDATE - Changer le rôle d'un utilisateur dans MerchantConfig
*/
updateUserRoleInMerchantConfig(
merchantConfigId: string,
userId: string,
newRole: UserRole
): Observable<MerchantUser> {
console.log('✏️ UPDATE User Role dans MerchantConfig...');
const updateRoleDto: UpdateUserRoleDto = {
role: newRole
};
this.updateKeycloakUserRole(userId, newRole)
return this.merchantConfigService.updateUserRole(
Number(merchantConfigId),
userId,
updateRoleDto
).pipe(
map(merchantConfigUser => {
console.log('✅ Rôle utilisateur mis à jour dans MerchantConfig:', merchantConfigUser);
return merchantConfigUser;
}),
catchError(error => {
console.error('❌ Erreur changement rôle utilisateur dans MerchantConfig:', error);
return throwError(() => error);
})
);
}
/**
* DELETE - Supprimer un merchant de MerchantConfig seulement
*/
deleteMerchantFromConfigOnly(
merchantConfigId: string
): Observable<{ success: boolean; message: string }> {
console.log('🗑️ DELETE Merchant de MerchantConfig seulement...');
return this.merchantConfigService.deleteMerchant(Number(merchantConfigId)).pipe(
map(() => ({
success: true,
message: 'Merchant supprimé de MerchantConfig avec succès'
})),
catchError(async (error) => ({
success: false,
message: `Erreur suppression merchant: ${error.message}`
}))
);
}
/**
* DELETE - Supprimer un utilisateur de Keycloak
*/
deleteKeycloakUser(
userId: string
): Observable<{ success: boolean; message: string }> {
console.log('🗑️ DELETE User de Keycloak...');
return this.getUserById(userId).pipe(
switchMap(user => {
const deleteService = this.merchantUsersService.deleteMerchantUser(userId);
return deleteService.pipe(
map(() => ({
success: true,
message: 'Utilisateur supprimé de Keycloak avec succès'
}))
);
}),
catchError(async (error) => ({
success: false,
message: `Erreur suppression utilisateur: ${error.message}`
}))
);
}
// ==================== MÉTHODES DE RECHERCHE ====================
/**
* Rechercher des merchants dans MerchantConfig
*/
searchMerchantsInConfig(query: string): Observable<Merchant[]> {
console.log('🔍 SEARCH Merchants dans MerchantConfig...');
return this.merchantConfigService.getAllMerchants({ query }).pipe(
map(merchants => {
console.log(`${merchants.length} merchants trouvés avec "${query}"`);
return merchants;
}),
catchError(error => {
console.error('❌ Erreur recherche merchants dans MerchantConfig:', error);
return throwError(() => error);
})
);
}
/**
* Rechercher des utilisateurs dans Keycloak
*/
searchKeycloakUsers(query: string): Observable<User[]> {
console.log('🔍 SEARCH Users dans Keycloak...');
// Rechercher dans les deux types d'utilisateurs
return forkJoin({
merchantUsers: this.merchantUsersService.searchMerchantUsers({ query }).pipe(
catchError(() => of([]))
)
}).pipe(
map(({ merchantUsers }) => {
const allUsers = [
...merchantUsers
];
console.log(`${allUsers.length} utilisateurs trouvés avec "${query}"`);
return allUsers;
}),
catchError(error => {
console.error('❌ Erreur recherche utilisateurs:', error);
return throwError(() => error);
})
);
}
// ==================== MÉTHODES PRIVÉES ====================
/**
* Récupérer un utilisateur par ID (gère les deux types)
*/
private getUserById(userId: string): Observable<User> {
return forkJoin({
merchantUser: this.merchantUsersService.getMerchantUserById(userId).pipe(
catchError(() => of(null))
)
}).pipe(
map(({ merchantUser }) => {
if (merchantUser) return merchantUser;
throw new Error(`Utilisateur ${userId} non trouvé`);
}),
catchError(error => {
console.error(`❌ Erreur récupération utilisateur ${userId}:`, error);
return throwError(() => error);
})
);
}
/**
* Vérifie si le rôle est un rôle partenaire
*/
private isMerchantRole(role: UserRole): boolean {
return [
UserRole.DCB_PARTNER_ADMIN,
UserRole.DCB_PARTNER_MANAGER,
UserRole.DCB_PARTNER_SUPPORT
].includes(role);
}
private mapToMerchantConfigRole(keycloakRole: UserRole): UserRole {
const mappedRole = this.ROLE_MAPPING.get(keycloakRole);
return mappedRole || UserRole.MERCHANT_CONFIG_VIEWER;
}
}

View File

@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { catchError, map, of, Subject, switchMap, takeUntil } from 'rxjs';
import { catchError, map, of, Subject, switchMap, takeUntil, throwError } from 'rxjs';
import { MerchantUsersService } from './merchant-users.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
@ -346,23 +346,49 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
}
private loadAllMerchants(): void {
if (this.isMerchantUser) {
console.log('⚠️ User is not a Hub user, merchant list not displayed');
return;
}
this.loadingMerchantPartners = true;
this.merchantPartnersError = '';
this.merchantPartners = [];
this.merchantConfigService.getAllMerchants()
const pageSize = 10; // batch size identique au backend
let currentPage = 1;
const loadNextPage = () => {
this.merchantConfigService.getAllMerchants(currentPage, pageSize)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (merchants) => {
this.merchantPartners = merchants;
this.loadingMerchantPartners = false;
console.log('✅ All merchants loaded for Hub Admin:', merchants.length);
next: response => {
const items = Array.isArray(response.items) ? response.items : [];
this.merchantPartners.push(...items);
const loadedCount = this.merchantPartners.length;
const totalItems = response.total;
console.log(`📥 Page ${currentPage} chargée (${loadedCount}/${totalItems} merchants)`);
if (loadedCount < totalItems) {
currentPage++;
loadNextPage();
} else {
this.loadingMerchantPartners = false;
console.log(`✅ Tous les merchants chargés: ${loadedCount}`);
}
},
error: (error) => {
console.error('❌ Error loading all merchants:', error);
error: err => {
console.error('❌ Error loading merchants:', err);
this.merchantPartnersError = 'Erreur lors du chargement des merchants';
this.loadingMerchantPartners = false;
}
});
};
loadNextPage();
}
getCurrentUserMerchantName(): string {
@ -571,15 +597,19 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
enabled: this.newUser.enabled,
emailVerified: this.newUser.emailVerified,
userType: this.newUser.userType,
merchantPartnerId: this.newUser.merchantPartnerId // Passer l'ID du merchant
merchantPartnerId: this.newUser.merchantPartnerId
};
// Variable pour stocker l'ID de l'utilisateur créé en cas de rollback
let createdUserId: string | null = null;
this.merchantUsersService.createMerchantUser(userDto)
.pipe(
switchMap((createdKeycloakUser) => {
console.log('✅ Keycloak user created successfully:', createdKeycloakUser);
createdUserId = createdKeycloakUser.id;
// 2. Ajouter l'utilisateur au merchant dans MerchantConfig
// 2. Si c'est un utilisateur Merchant, l'ajouter au merchant dans MerchantConfig
if (this.isMerchantRole(this.newUser.role) && this.newUser.merchantPartnerId) {
const merchantPartnerId = Number(this.newUser.merchantPartnerId);
@ -589,38 +619,94 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
merchantPartnerId: merchantPartnerId
};
console.log('📤 Adding user to merchant config:', addUserDto);
return this.merchantConfigService.addUserToMerchant(addUserDto).pipe(
map((merchantConfigUser) => {
return {
keycloakUser: createdKeycloakUser,
merchantConfigUser
merchantConfigUser,
success: true
};
}),
catchError((merchantError) => {
console.error('❌ Failed to add user to merchant config:', merchantError);
// ROLLBACK: Supprimer l'utilisateur Keycloak créé
if (createdUserId) {
console.log(`🔄 Rollback: Deleting Keycloak user ${createdUserId} because merchant association failed`);
return this.merchantUsersService.deleteMerchantUser(createdUserId).pipe(
switchMap(() => {
console.log(`✅ Keycloak user ${createdUserId} deleted as part of rollback`);
// On propage l'erreur originale en créant un Observable qui échoue
return throwError(() => new Error(`Failed to associate user with merchant: ${merchantError.message}. User creation rolled back.`));
}),
catchError((deleteError) => {
console.error(`❌ Failed to delete Keycloak user during rollback:`, deleteError);
return throwError(() => new Error(`Failed to associate user with merchant: ${merchantError.message}. AND failed to rollback user creation: ${deleteError.message}`));
})
);
}
// Si createdUserId est null, on ne peut pas rollback
throw merchantError;
})
);
}
return of({ keycloakUser: createdKeycloakUser });
// Si pas d'association nécessaire (non-Merchant user), retourner directement
return of({
keycloakUser: createdKeycloakUser,
merchantConfigUser: null,
success: true
});
}),
takeUntil(this.destroy$)
)
.subscribe({
next: (result) => {
console.log('✅ Complete user creation successful:', result);
this.creatingUser = false;
this.modalService.dismissAll();
this.refreshUsersList();
this.cdRef.detectChanges();
if (result.success) {
console.log('✅ Complete user creation successful:', result);
this.creatingUser = false;
this.modalService.dismissAll();
this.refreshUsersList();
this.cdRef.detectChanges();
}
},
error: (error) => {
console.error('❌ Error in user creation process:', error);
this.creatingUser = false;
this.createUserError = this.getErrorMessage(error);
this.cdRef.detectChanges();
// Déterminer le message d'erreur approprié
if (error.message.includes('rolled back')) {
// Erreur avec rollback réussi
this.createUserError = error.message;
console.log('⚠️ User creation rolled back successfully');
} else if (createdUserId && !error.message.includes('failed to rollback')) {
// L'utilisateur a été créé mais une autre erreur est survenue
// Tentative de nettoyage de l'utilisateur orphelin
console.log(`⚠️ Attempting to clean up orphaned user: ${createdUserId}`);
this.merchantUsersService.deleteMerchantUser(createdUserId).subscribe({
next: () => {
console.log(`✅ Orphaned user cleaned up: ${createdUserId}`);
this.createUserError = `User creation failed after user was created. Orphaned user ${createdUserId} has been cleaned up. Error: ${error.message}`;
this.cdRef.detectChanges();
},
error: (cleanupError) => {
console.error(`❌ Failed to cleanup orphaned user:`, cleanupError);
this.createUserError = `User creation failed and cleanup of orphaned user ${createdUserId} also failed. Please contact admin. Original error: ${error.message}`;
this.cdRef.detectChanges();
}
});
} else {
// Autre erreur
this.createUserError = this.getErrorMessage(error);
this.cdRef.detectChanges();
}
}
});
}
// Méthode pour trouver le merchantPartnerId de l'utilisateur connecté
private findMerchantPartnerIdForCurrentUser(): void {
const currentUserId = this.authService.getCurrentUserId();
@ -791,16 +877,28 @@ export class MerchantUsersManagement implements OnInit, OnDestroy {
if (error.error?.message) {
return error.error.message;
}
if (error.status === 400) {
return 'Données invalides. Vérifiez les champs du formulaire.';
}
if (error.status === 409) {
return 'Un utilisateur avec ce nom d\'utilisateur ou email existe déjà.';
}
if (error.status === 403) {
return 'Vous n\'avez pas les permissions nécessaires pour cette action.';
}
return 'Erreur lors de la création de l\'utilisateur. Veuillez réessayer.';
if (error.status === 400) {
return 'Données invalides. Vérifiez les informations saisies';
}
if (error.status === 404) {
return 'Le merchant spécifié n\'existe pas';
}
if (error.status === 0 || error.status === 503) {
return 'Service temporairement indisponible. Veuillez réessayer plus tard';
}
return 'Une erreur inattendue est survenue lors de l\'Operation';
}
private getResetPasswordErrorMessage(error: any): string {

View File

@ -1 +0,0 @@
<p>Integrations</p>

View File

@ -1,2 +0,0 @@
import { Integrations } from './integrations';
describe('Integrations', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-integrations',
templateUrl: './integrations.html',
})
export class Integrations {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class IntegrationsService {
constructor() {}
}

View File

@ -180,20 +180,40 @@
<tr>
<td>
<div class="d-flex align-items-center">
@if (merchant.logo) {
<img
[src]="merchant.logo"
alt="Logo {{ merchant.name }}"
class="avatar-sm rounded-circle me-2"
onerror="this.style.display='none'"
>
}
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideStore" class="text-primary fs-12"></ng-icon>
<!-- ==================== LOGO AVEC ICÔNE ==================== -->
<div class="d-flex align-items-center me-3">
<!-- Icône fixe à gauche -->
<div class="avatar-sm bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2">
<ng-icon name="lucideStore" class="text-primary fs-12"></ng-icon>
</div>
<!-- Logo du marchand -->
<div>
@if (merchant.logo && merchant.logo.trim() !== '') {
<img
[src]="getMerchantLogoUrl(merchant.id, merchant.logo, merchant.name) | async"
[alt]="merchant.name + ' logo'"
class="rounded-circle"
style="width: 40px; height: 40px; object-fit: cover; border: 2px solid #f0f0f0;"
loading="lazy"
(error)="onLogoError($event, merchant.name)"
/>
} @else {
<img
[src]="getDefaultLogoUrl(merchant.name)"
[alt]="merchant.name + ' logo'"
class="rounded-circle"
style="width: 40px; height: 40px; object-fit: cover; border: 2px solid #f0f0f0;"
loading="lazy"
/>
}
</div>
</div>
<div>
<strong class="d-block">{{ merchant.name }}</strong>
<small class="text-muted">{{ merchant.adresse }}</small>
<!-- Informations du marchand -->
<div class="min-w-0 flex-grow-1">
<strong class="d-block text-truncate">{{ merchant.name }}</strong>
<small class="text-muted text-truncate d-block">{{ merchant.adresse }}</small>
</div>
</div>
</td>
@ -314,7 +334,7 @@
</table>
<!-- Pagination -->
@if (totalPages > 1) {
@if (totalPages >= 1) {
<div class="d-flex justify-content-between align-items-center mt-3">
<div class="text-muted">
Affichage de {{ getStartIndex() }}

View File

@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap';
import { Observable, Subject, of } from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import { catchError, map, takeUntil, tap } from 'rxjs/operators';
import {
Merchant,
@ -20,6 +20,8 @@ import { MerchantConfigService } from '../merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { UiCard } from '@app/components/ui-card';
import { DomSanitizer } from '@angular/platform-browser';
import { MinioService } from '@core/services/minio.service';
@Component({
selector: 'app-merchant-config-list',
@ -40,6 +42,13 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
private minioService = inject(MinioService);
// Cache des URLs de logos
private logoUrlCache = new Map<string, string>();
// Ajouter un cache pour les logos non trouvés
private logoErrorCache = new Set<string>();
// Configuration
readonly ConfigType = ConfigType;
readonly Operator = Operator;
@ -103,11 +112,6 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
this.loadCurrentUserPermissions();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private loadCurrentUserPermissions() {
this.authService.getUserProfile()
.pipe(takeUntil(this.destroy$))
@ -150,7 +154,7 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
];
}
loadMerchants() {
loadMerchants(): void {
if (!this.isHubUser) {
console.log('⚠️ User is not a Hub user, merchant list not displayed');
return;
@ -159,15 +163,14 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
this.loading = true;
this.error = '';
this.merchantConfigService.getMerchants(
this.currentPage,
this.itemsPerPage,
this.buildSearchParams()
)
const params = this.buildSearchParams();
const skip = (this.currentPage - 1) * this.itemsPerPage;
this.merchantConfigService.getAllMerchants(this.currentPage, this.itemsPerPage, params)
.pipe(
takeUntil(this.destroy$),
catchError(error => {
console.error('Error loading merchants:', error);
console.error('Error loading merchants:', error);
this.error = 'Erreur lors du chargement des marchands';
return of({
items: [],
@ -178,36 +181,196 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
} as PaginatedResponse<Merchant>);
})
)
.subscribe({
next: (response) => {
console.log('📊 Pagination response:', {
page: response.page,
total: response.total,
totalPages: response.totalPages,
itemsCount: response.items?.length,
limit: response.limit
});
.subscribe(response => {
this.allMerchants = response.items || [];
this.displayedMerchants = response.items || [];
this.totalItems = response.total || 0;
this.totalPages = response.totalPages || Math.ceil((response.total || 0) / this.itemsPerPage);
this.allMerchants = response.items || [];
this.displayedMerchants = response.items || [];
this.totalItems = response.total || 0;
this.totalPages = response.totalPages || 0;
this.loading = false;
this.cdRef.detectChanges();
this.loading = false;
this.cdRef.detectChanges();
},
error: () => {
this.error = 'Erreur lors du chargement des marchands';
this.loading = false;
this.allMerchants = [];
this.displayedMerchants = [];
this.totalItems = 0;
this.totalPages = 0;
this.cdRef.detectChanges();
}
console.log('📊 Pagination response:', {
page: response.page,
total: response.total,
totalPages: this.totalPages,
itemsCount: response.items?.length,
limit: response.limit
});
});
}
// ==================== AFFICHAGE DU LOGO ====================
/**
* Récupère l'URL du logo avec fallback automatique
*/
getMerchantLogoUrl(
merchantId: number | undefined,
logoFileName: string,
merchantName: string
): Observable<string> {
const newMerchantId = String(merchantId);
const cacheKey = `${merchantId}_${logoFileName}`;
// Vérifier si le logo est en cache d'erreur
if (this.logoErrorCache.has(cacheKey)) {
const defaultLogo = this.getDefaultLogoUrl(merchantName);
return of(defaultLogo);
}
// Vérifier le cache normal
if (this.logoUrlCache.has(cacheKey)) {
return of(this.logoUrlCache.get(cacheKey)!);
}
// Récupérer l'URL depuis l'API avec la nouvelle structure
return this.minioService.getMerchantLogoUrl(
newMerchantId,
logoFileName,
{ signed: true, expirySeconds: 3600 }
).pipe(
map(response => {
// Extraire l'URL de la réponse
const url = response.data.url ;
// Mettre en cache avec la clé composite
this.logoUrlCache.set(cacheKey, url);
return url;
}),
catchError(error => {
console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error);
// En cas d'erreur, ajouter au cache d'erreur
this.logoErrorCache.add(cacheKey);
// Générer un logo par défaut
const defaultLogo = this.getDefaultLogoUrl(merchantName);
// Mettre le logo par défaut dans le cache normal aussi
this.logoUrlCache.set(cacheKey, defaultLogo);
return of(defaultLogo);
})
);
}
/**
* Génère une URL de logo par défaut basée sur les initiales
*/
getDefaultLogoUrl(merchantName: string): string {
// Créer des initiales significatives
const initials = this.extractInitials(merchantName);
// Palette de couleurs agréables
const colors = [
'667eea', // Violet
'764ba2', // Violet foncé
'f56565', // Rouge
'4299e1', // Bleu
'48bb78', // Vert
'ed8936', // Orange
'FF6B6B', // Rouge clair
'4ECDC4', // Turquoise
'45B7D1', // Bleu clair
'96CEB4' // Vert menthe
];
const colorIndex = merchantName.length % colors.length;
const backgroundColor = colors[colorIndex];
// Taille fixe à 80px (l'API génère un carré de cette taille)
// L'image sera redimensionnée à 40px via CSS
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`;
}
/**
* Extrait les initiales de manière intelligente
*/
private extractInitials(name: string): string {
if (!name || name.trim() === '') {
return '??';
}
// Nettoyer le nom
const cleanedName = name.trim().toUpperCase();
// Extraire les mots
const words = cleanedName.split(/\s+/);
// Si un seul mot, prendre les deux premières lettres
if (words.length === 1) {
return words[0].substring(0, 2) || '??';
}
// Prendre la première lettre des deux premiers mots
const initials = words
.slice(0, 2) // Prendre les 2 premiers mots
.map(word => word[0] || '')
.join('');
return initials || name.substring(0, 2).toUpperCase() || '??';
}
/**
* Gère les erreurs de chargement des logos MinIO
*/
onLogoError(event: Event, merchantName: string): void {
const img = event.target as HTMLImageElement;
if (!img) return;
console.warn('Logo MinIO failed to load, using default for:', merchantName);
img.onerror = null;
img.src = this.getDefaultLogoUrl(merchantName);
}
/**
* Gère les erreurs de chargement des logos par défaut
*/
onDefaultLogoError(event: Event | string): void {
if (!(event instanceof Event)) {
console.error('Default logo error (non-event):', event);
return;
}
const img = event.target as HTMLImageElement | null;
if (!img) return;
console.error('Default logo also failed to load, using fallback SVG');
// SVG local
img.onerror = null; // éviter boucle infinie
img.src = 'assets/images/default-merchant-logo.svg';
// Dernier recours
img.onerror = (e) => {
if (!(e instanceof Event)) return;
const fallbackImg = e.target as HTMLImageElement | null;
if (!fallbackImg) return;
fallbackImg.onerror = null;
fallbackImg.src = this.generateFallbackDataUrl();
};
}
/**
* Génère un fallback SVG en data URL
*/
private generateFallbackDataUrl(): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect width="40" height="40" fill="#667eea" rx="20"/>
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
}
private buildSearchParams(): SearchMerchantsParams {
const params: SearchMerchantsParams = {};
@ -370,4 +533,15 @@ export class MerchantConfigsList implements OnInit, OnDestroy {
shouldDisplayMerchantList(): boolean {
return this.isHubUser;
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Nettoyer les caches
this.logoUrlCache.clear();
this.logoErrorCache.clear();
}
}

View File

@ -7,11 +7,13 @@
<h4 class="mb-1">Profil Marchand</h4>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item">
<a href="javascript:void(0)" (click)="goBack()" class="text-decoration-none cursor-pointer">
Marchands
</a>
</li>
@if(isHubUser){
<li class="breadcrumb-item">
<a href="javascript:void(0)" (click)="goBack()" class="text-decoration-none cursor-pointer">
Marchands
</a>
</li>
}
<li class="breadcrumb-item active" aria-current="page">
{{ merchant?.name || 'Chargement...' }}
</li>
@ -29,15 +31,16 @@
<ng-icon name="lucideRefreshCw" class="me-1" [class.spin]="loading"></ng-icon>
Actualiser
</button>
<!-- Bouton retour -->
<button
class="btn btn-outline-secondary"
(click)="goBack()"
>
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour
</button>
@if(isHubUser){
<!-- Bouton retour -->
<button
class="btn btn-outline-secondary"
(click)="goBack()"
>
<ng-icon name="lucideArrowLeft" class="me-1"></ng-icon>
Retour
</button>
}
</div>
</div>
</div>
@ -106,61 +109,128 @@
<ng-template ngbNavContent>
<!-- Vue d'ensemble -->
<div class="p-3">
<!-- En-tête du profil -->
<div class="profile-section">
<div class="profile-header">
<div class="row align-items-center">
<div class="col-md-8">
<h2 class="mb-2">{{ merchant.name }}</h2>
<p class="mb-0 opacity-75">{{ merchant.description || 'Aucune description' }}</p>
</div>
<div class="col-md-4 text-md-end">
@if (canEditMerchant()) {
<button
class="btn btn-light"
(click)="editMerchant(merchant)"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier le profil
</button>
}
<!-- En-tête du profil avec logo -->
<div class="profile-section">
<div class="profile-header">
<div class="row align-items-center">
<!-- Logo et informations -->
<div class="col-md-8">
<div class="d-flex align-items-start">
<!-- ==================== LOGO DU MARCHAND ==================== -->
<div class="merchant-logo-container me-4">
<div class="logo-display">
@if (merchant.logo && merchant.logo.trim() !== '') {
<img
[src]="getMerchantLogoUrl(merchant.id, merchant.logo, merchant.name) | async"
[alt]="merchant.name + ' logo'"
class="merchant-logo"
style="width: 300px; height: 250px; object-fit: cover; border-radius: 8px; border: 2px solid #f0f0f0;"
loading="lazy"
(error)="onLogoError($event, merchant.name)"
/>
} @else {
<img
[src]="getDefaultLogoUrl(merchant.name)"
[alt]="merchant.name + ' logo'"
class="merchant-logo"
style="width: 200px; height: 200px; object-fit: cover; border-radius: 8px; border: 2px solid #f0f0f0;"
loading="lazy"
(error)="onDefaultLogoError($event)"
/>
}
</div>
<div class="logo-info mt-2 text-center">
<div style="font-size: 0.85rem; color: #666;">
{{ merchant.logo ? 'Logo personnalisé' : 'Logo par défaut' }}
</div>
</div>
</div>
<!-- Informations du marchand -->
<div class="flex-grow-1">
<h2 class="mb-2 text-white">{{ merchant.name }}</h2>
@if (merchant.description) {
<p class="mb-0 text-white opacity-75">{{ merchant.description }}</p>
} @else {
<p class="mb-0 text-white opacity-50 fst-italic">Aucune description</p>
}
<!-- Informations de contact -->
<div class="mt-3 d-flex flex-wrap gap-3">
<div class="d-flex align-items-center text-white opacity-75">
<ng-icon name="lucideMapPin" size="16" class="me-2"></ng-icon>
<small>{{ merchant.adresse }}</small>
</div>
<div class="d-flex align-items-center text-white opacity-75">
<ng-icon name="lucidePhone" size="16" class="me-2"></ng-icon>
<small>{{ merchant.phone }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Statistiques -->
<div class="p-3">
<div class="row g-3">
@if (getMerchantStats(); as stats) {
<div class="col-md-3">
<div class="stats-card">
<div class="stats-number">{{ stats.configs.total }}</div>
<div class="stats-label">Configurations</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-number">{{ stats.contacts.total }}</div>
<div class="stats-label">Contacts</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-number">{{ stats.users.total }}</div>
<div class="stats-label">Utilisateurs</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-number">{{ stats.configs.sensitive }}</div>
<div class="stats-label">Configs sensibles</div>
</div>
</div>
<!-- Actions -->
<div class="col-md-4 text-md-end">
@if (canEditMerchant()) {
<button
class="btn btn-light"
(click)="editMerchant(merchant)"
>
<ng-icon name="lucideEdit" class="me-1"></ng-icon>
Modifier le profil
</button>
}
</div>
</div>
</div>
<!-- Statistiques -->
<div class="p-3">
<div class="row g-3">
@if (getMerchantStats(); as stats) {
<div class="col-md-3">
<div class="stats-card">
<div class="stats-icon mb-2">
<ng-icon name="lucideSettings" class="text-primary"></ng-icon>
</div>
<div class="stats-number">{{ stats.configs.total }}</div>
<div class="stats-label">Configurations</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-icon mb-2">
<ng-icon name="lucideUsers" class="text-info"></ng-icon>
</div>
<div class="stats-number">{{ stats.contacts.total }}</div>
<div class="stats-label">Contacts</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-icon mb-2">
<ng-icon name="lucideUserCheck" class="text-success"></ng-icon>
</div>
<div class="stats-number">{{ stats.users.total }}</div>
<div class="stats-label">Utilisateurs</div>
</div>
</div>
<div class="col-md-3">
<div class="stats-card">
<div class="stats-icon mb-2">
<ng-icon name="lucideShield" class="text-warning"></ng-icon>
</div>
<div class="stats-number">{{ stats.configs.sensitive }}</div>
<div class="stats-label">Configs sensibles</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Informations principales -->
<div class="row g-4">
<!-- Configurations récentes -->

View File

@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbAlertModule, NgbPaginationModule, NgbNavModule, NgbModal } from '@ng-bootstrap/ng-bootstrap';
import { Subject, takeUntil } from 'rxjs';
import { catchError, map, Observable, of, Subject, takeUntil, tap } from 'rxjs';
import {
Merchant,
@ -21,7 +21,9 @@ import { MerchantConfigService } from '../merchant-config.service';
import { MerchantDataAdapter } from '../merchant-data-adapter.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { UserRole } from '@core/models/dcb-bo-hub-user.model';
import { UserRole, UserType } from '@core/models/dcb-bo-hub-user.model';
import { MinioService } from '@core/services/minio.service';
import { DomSanitizer } from '@angular/platform-browser';
@Component({
selector: 'app-merchant-config-view',
@ -180,6 +182,19 @@ export class MerchantConfigView implements OnInit, OnDestroy {
private modalService = inject(NgbModal);
private destroy$ = new Subject<void>();
private minioService = inject(MinioService);
// Cache des URLs de logos
private logoUrlCache = new Map<string, string>();
// Ajouter un cache pour les logos non trouvés
private logoErrorCache = new Set<string>();
private deleteModalRef: any = null;
// Cache
private merchantCache: { data: Merchant, timestamp: number } | null = null;
private readonly CACHE_TTL = 2 * 60 * 1000; // 2 minutes
readonly ConfigType = ConfigType;
readonly Operator = Operator;
readonly MerchantUtils = MerchantUtils;
@ -203,12 +218,14 @@ export class MerchantConfigView implements OnInit, OnDestroy {
// Gestion des permissions
currentUserRole: UserRole | null = null;
currentMerchantPartnerId: string = '';
// Déterminer le type d'utilisateur
isMerchantUser = false;
isHubUser = false;
// Édition des configurations
editingConfigId: number | null = null;
editedConfig: UpdateMerchantConfigDto = {};
configToDelete: MerchantConfig | null = null;
private deleteModalRef: any = null;
// Affichage des valeurs sensibles
showSensitiveValues: { [configId: number]: boolean } = {};
@ -222,10 +239,6 @@ export class MerchantConfigView implements OnInit, OnDestroy {
page = 1;
pageSize = 5;
// Cache
private merchantCache: { data: Merchant, timestamp: number } | null = null;
private readonly CACHE_TTL = 2 * 60 * 1000; // 2 minutes
ngOnInit() {
if (this.merchantId) {
this.loadCurrentUserPermissions();
@ -281,6 +294,176 @@ export class MerchantConfigView implements OnInit, OnDestroy {
});
}
// ==================== AFFICHAGE DU LOGO ====================
/**
* Récupère l'URL du logo avec fallback automatique
*/
getMerchantLogoUrl(
merchantId: number | undefined,
logoFileName: string,
merchantName: string
): Observable<string> {
const newMerchantId = String(merchantId);
const cacheKey = `${merchantId}_${logoFileName}`;
// Vérifier si le logo est en cache d'erreur
if (this.logoErrorCache.has(cacheKey)) {
const defaultLogo = this.getDefaultLogoUrl(merchantName);
return of(defaultLogo);
}
// Vérifier le cache normal
if (this.logoUrlCache.has(cacheKey)) {
return of(this.logoUrlCache.get(cacheKey)!);
}
// Récupérer l'URL depuis l'API avec la nouvelle structure
return this.minioService.getMerchantLogoUrl(
newMerchantId,
logoFileName,
{ signed: true, expirySeconds: 3600 }
).pipe(
map(response => {
// Extraire l'URL de la réponse
const url = response.data.url ;
// Mettre en cache avec la clé composite
this.logoUrlCache.set(cacheKey, url);
return url;
}),
catchError(error => {
console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error);
// En cas d'erreur, ajouter au cache d'erreur
this.logoErrorCache.add(cacheKey);
// Générer un logo par défaut
const defaultLogo = this.getDefaultLogoUrl(merchantName);
// Mettre le logo par défaut dans le cache normal aussi
this.logoUrlCache.set(cacheKey, defaultLogo);
return of(defaultLogo);
})
);
}
/**
* Génère une URL de logo par défaut basée sur les initiales
*/
getDefaultLogoUrl(merchantName: string): string {
// Créer des initiales significatives
const initials = this.extractInitials(merchantName);
// Palette de couleurs agréables
const colors = [
'667eea', // Violet
'764ba2', // Violet foncé
'f56565', // Rouge
'4299e1', // Bleu
'48bb78', // Vert
'ed8936', // Orange
'FF6B6B', // Rouge clair
'4ECDC4', // Turquoise
'45B7D1', // Bleu clair
'96CEB4' // Vert menthe
];
const colorIndex = merchantName.length % colors.length;
const backgroundColor = colors[colorIndex];
// Taille fixe à 80px (l'API génère un carré de cette taille)
// L'image sera redimensionnée à 40px via CSS
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`;
}
/**
* Extrait les initiales de manière intelligente
*/
private extractInitials(name: string): string {
if (!name || name.trim() === '') {
return '??';
}
// Nettoyer le nom
const cleanedName = name.trim().toUpperCase();
// Extraire les mots
const words = cleanedName.split(/\s+/);
// Si un seul mot, prendre les deux premières lettres
if (words.length === 1) {
return words[0].substring(0, 2) || '??';
}
// Prendre la première lettre des deux premiers mots
const initials = words
.slice(0, 2) // Prendre les 2 premiers mots
.map(word => word[0] || '')
.join('');
return initials || name.substring(0, 2).toUpperCase() || '??';
}
/**
* Gère les erreurs de chargement des logos MinIO
*/
onLogoError(event: Event, merchantName: string): void {
const img = event.target as HTMLImageElement;
if (!img) return;
console.warn('Logo MinIO failed to load, using default for:', merchantName);
img.onerror = null;
img.src = this.getDefaultLogoUrl(merchantName);
}
/**
* Gère les erreurs de chargement des logos par défaut
*/
onDefaultLogoError(event: Event | string): void {
if (!(event instanceof Event)) {
console.error('Default logo error (non-event):', event);
return;
}
const img = event.target as HTMLImageElement | null;
if (!img) return;
console.error('Default logo also failed to load, using fallback SVG');
// SVG local
img.onerror = null; // éviter boucle infinie
img.src = 'assets/images/default-merchant-logo.svg';
// Dernier recours
img.onerror = (e) => {
if (!(e instanceof Event)) return;
const fallbackImg = e.target as HTMLImageElement | null;
if (!fallbackImg) return;
fallbackImg.onerror = null;
fallbackImg.src = this.generateFallbackDataUrl();
};
}
/**
* Génère un fallback SVG en data URL
*/
private generateFallbackDataUrl(): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect width="40" height="40" fill="#667eea" rx="20"/>
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
}
/**
* Charge les permissions de l'utilisateur courant
*/
@ -290,6 +473,18 @@ export class MerchantConfigView implements OnInit, OnDestroy {
.subscribe({
next: (profile) => {
this.currentUserRole = this.authService.getCurrentUserRole();
// Déterminer si c'est un utilisateur marchand
if (this.currentUserRole === UserRole.DCB_PARTNER_ADMIN ||
this.currentUserRole === UserRole.DCB_PARTNER_MANAGER ||
this.currentUserRole === UserRole.DCB_PARTNER_SUPPORT ||
this.currentUserRole === UserRole.MERCHANT_CONFIG_MANAGER ||
this.currentUserRole === UserRole.MERCHANT_CONFIG_TECHNICAL ||
this.currentUserRole === UserRole.MERCHANT_CONFIG_VIEWER) {
this.isMerchantUser = true;
} else {
this.isHubUser = profile?.userType === UserType.HUB;
this.isMerchantUser = profile?.userType === UserType.MERCHANT_PARTNER;
}
this.cdRef.detectChanges();
},
error: (error) => {

View File

@ -177,15 +177,91 @@
</div>
<div class="col-md-6">
<label class="form-label">Logo URL</label>
<input
type="text"
class="form-control"
placeholder="https://exemple.com/logo.png"
[(ngModel)]="newMerchant.logo"
name="logo"
[disabled]="creatingMerchant"
>
<div class="form-group logo-upload-section">
<label class="form-label">
<ng-icon name="lucideImage" class="me-1"></ng-icon>
Logo du marchand (optionnel)
</label>
<div class="logo-upload-container">
<!-- Zone de prévisualisation -->
<div class="logo-preview-area">
@if (logoPreviewUrl) {
<!-- Image sélectionnée -->
<div class="logo-preview">
<img [src]="logoPreviewUrl" alt="Prévisualisation du logo">
<button
type="button"
class="btn-remove-preview"
(click)="removeSelectedLogo()"
[disabled]="creatingMerchant || uploadingLogo"
title="Supprimer"
>
<ng-icon name="lucideX"></ng-icon>
</button>
</div>
} @else {
<!-- Placeholder -->
<div class="logo-placeholder">
<ng-icon name="lucideImage" size="48" class="text-muted mb-2"></ng-icon>
<p class="text-muted mb-0">Aucun logo sélectionné</p>
</div>
}
</div>
<!-- Bouton de sélection -->
<div class="logo-upload-actions">
<input
type="file"
id="logoInput"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/svg+xml"
(change)="onLogoSelected($event)"
[disabled]="creatingMerchant || uploadingLogo"
style="display: none;"
/>
<label
for="logoInput"
class="btn btn-outline-primary btn-select-logo"
[class.disabled]="creatingMerchant || uploadingLogo"
>
<ng-icon name="lucideUpload" class="me-1"></ng-icon>
{{ logoPreviewUrl ? 'Changer le logo' : 'Sélectionner un logo' }}
</label>
<small class="text-muted d-block mt-2">
<ng-icon name="lucideInfo" size="12" class="me-1"></ng-icon>
Formats acceptés : JPG, PNG, GIF, WebP, SVG | Taille max : 5MB
</small>
@if (selectedLogoFile) {
<div class="mt-2">
<small class="badge bg-info">
<ng-icon name="lucideFile" size="12" class="me-1"></ng-icon>
{{ selectedLogoFile.name }} ({{ formatFileSize(selectedLogoFile.size) }})
</small>
</div>
}
</div>
</div>
<!-- Erreur upload logo -->
@if (logoUploadError) {
<div class="alert alert-danger mt-2">
<ng-icon name="lucideAlertCircle" class="me-1"></ng-icon>
{{ logoUploadError }}
</div>
}
<!-- Indicateur d'upload -->
@if (uploadingLogo) {
<div class="upload-progress">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Upload en cours...</span>
</div>
<span class="ms-2">Upload du logo en cours...</span>
</div>
}
</div>
</div>
<div class="col-12">
@ -517,15 +593,102 @@
</div>
<div class="col-md-6">
<label class="form-label">Logo URL</label>
<input
type="text"
class="form-control"
[(ngModel)]="selectedMerchantForEdit.logo"
name="logo"
[disabled]="updatingMerchant"
placeholder="https://exemple.com/logo.png"
>
<div class="form-group logo-edit-section">
<label class="form-label">
<ng-icon name="lucideImage" class="me-1"></ng-icon>
Logo du marchand
</label>
<div class="logo-edit-container">
<!-- Logo actuel ou nouveau -->
<div class="logo-display-area">
@if (editLogoPreviewUrl) {
<!-- Nouveau logo sélectionné -->
<div class="logo-preview">
<img [src]="editLogoPreviewUrl" alt="Nouveau logo">
<div class="logo-badge badge-new">Nouveau</div>
<button
type="button"
class="btn-remove-preview"
(click)="cancelEditLogo()"
[disabled]="updatingMerchant || uploadingLogo"
title="Annuler"
>
<ng-icon name="lucideX"></ng-icon>
</button>
</div>
} @else if (currentLogoUrl) {
<!-- Logo actuel -->
<div class="logo-preview">
<img [src]="currentLogoUrl" alt="Logo actuel">
<div class="logo-badge badge-current">Actuel</div>
</div>
} @else {
<!-- Pas de logo -->
<div class="logo-placeholder">
<ng-icon name="lucideImage" size="48" class="text-muted mb-2"></ng-icon>
<p class="text-muted mb-0">Aucun logo</p>
</div>
}
</div>
<!-- Actions -->
<div class="logo-edit-actions">
<input
type="file"
id="editLogoInput"
accept="image/jpeg,image/jpg,image/png,image/gif,image/webp,image/svg+xml"
(change)="onEditLogoSelected($event)"
[disabled]="updatingMerchant || uploadingLogo"
style="display: none;"
/>
<label
for="editLogoInput"
class="btn btn-outline-primary btn-change-logo"
[class.disabled]="updatingMerchant || uploadingLogo"
>
<ng-icon name="lucideRefreshCw" class="me-1"></ng-icon>
{{ currentLogoUrl ? 'Changer le logo' : 'Ajouter un logo' }}
</label>
@if (currentLogoUrl && !editLogoPreviewUrl) {
<button
type="button"
class="btn btn-outline-danger btn-remove-logo"
(click)="selectedMerchantForEdit!.logo = ''; currentLogoUrl = null"
[disabled]="updatingMerchant"
>
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer le logo
</button>
}
<small class="text-muted d-block mt-2">
<ng-icon name="lucideInfo" size="12" class="me-1"></ng-icon>
Formats : JPG, PNG, GIF, WebP, SVG | Max : 5MB
</small>
@if (editLogoFile) {
<div class="mt-2">
<small class="badge bg-info">
<ng-icon name="lucideFile" size="12" class="me-1"></ng-icon>
{{ editLogoFile.name }} ({{ formatFileSize(editLogoFile.size) }})
</small>
</div>
}
</div>
</div>
<!-- Indicateur d'upload -->
@if (uploadingLogo) {
<div class="upload-progress">
<div class="spinner-border spinner-border-sm" role="status">
<span class="visually-hidden">Upload en cours...</span>
</div>
<span class="ms-2">Upload du nouveau logo en cours...</span>
</div>
}
</div>
</div>
<div class="col-12">
@ -567,104 +730,160 @@
</div>
</div>
<!-- CONFIGURATIONS TECHNIQUES -->
<div class="row g-3 mb-4">
<!-- LISTE DES CONFIGURATIONS (READONLY) -->
<div class="row g-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center border-bottom pb-2">
<div class="d-flex justify-content-between align-items-center border-bottom pb-2 mb-3">
<h6 class="mb-0 text-primary">
<ng-icon name="lucideSettings" class="me-2"></ng-icon>
Configurations Techniques
Configurations
</h6>
<button
type="button"
class="btn btn-outline-primary btn-sm"
(click)="addConfigInEdit()"
[disabled]="updatingMerchant"
>
<ng-icon name="lucidePlus" class="me-1"></ng-icon>
Ajouter une configuration
</button>
<span class="badge bg-secondary">{{ selectedMerchantForEdit.configs.length || 0 }} config(s)</span>
</div>
</div>
@if (!selectedMerchantForEdit.configs || selectedMerchantForEdit.configs.length === 0) {
<div class="col-12">
<div class="alert alert-warning">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Au moins une configuration est requise
<div class="alert alert-info">
<ng-icon name="lucideInfo" class="me-2"></ng-icon>
Aucune configuration disponible
</div>
</div>
}
<!-- Liste des configurations -->
@for (config of selectedMerchantForEdit.configs; track trackByConfigId($index, config); let i = $index) {
<!-- Liste des configurations en mode lecture -->
@for (config of selectedMerchantForEdit.configs; track config.id || $index; let i = $index) {
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card border-0 shadow-sm mb-3">
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<ng-icon [name]="getConfigTypeIconSafe(config.name)" class="me-2 text-primary"></ng-icon>
<ng-icon name="lucideSettings" class="me-2 text-primary"></ng-icon>
<span class="fw-semibold">Configuration {{ i + 1 }}</span>
</div>
@if (selectedMerchantForEdit.configs.length > 1) {
<button
type="button"
class="btn btn-sm btn-outline-danger"
(click)="removeConfigInEdit(i)"
[disabled]="updatingMerchant"
>
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer
</button>
@if (config.name.includes('SECRET') || config.name.includes('KEY') || config.value.includes('password')) {
<span class="badge bg-warning text-dark">
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Sensible
</span>
}
</div>
<div class="card-body">
<div class="row g-3">
<!-- Type de configuration -->
<div class="col-md-6">
<label class="form-label">Type <span class="text-danger">*</span></label>
<select
class="form-select"
[(ngModel)]="config.name"
[name]="'editConfigType_' + i"
required
[disabled]="updatingMerchant"
>
<option value="" disabled>Sélectionnez un type</option>
@for (type of configTypes; track type.name) {
<option [value]="type.name">{{ type.label }}</option>
}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Opérateur <span class="text-danger">*</span></label>
<select
class="form-select"
[(ngModel)]="config.operatorId"
[name]="'editOperatorId_' + i"
required
[disabled]="updatingMerchant"
>
<option value="" disabled>Sélectionnez un opérateur</option>
@for (operator of operators; track operator.id) {
<option [value]="operator.id">{{ operator.name }}</option>
}
</select>
</div>
<div class="col-12">
<label class="form-label">Valeur <span class="text-danger">*</span></label>
<textarea
class="form-control font-monospace"
[(ngModel)]="config.value"
[name]="'editValue_' + i"
required
[disabled]="updatingMerchant"
rows="3"
placeholder="Valeur de configuration"
></textarea>
@if (isSensitiveConfig(config)) {
<div class="form-text text-warning">
<ng-icon name="lucideShield" class="me-1"></ng-icon>
Cette configuration contient des informations sensibles
<div class="mb-2">
<small class="text-muted d-block">Type</small>
<div class="d-flex align-items-center">
<ng-icon name="lucideSettings" class="me-2 text-muted"></ng-icon>
<span class="fw-medium">
{{ config.name || 'Non spécifié' }}
</span>
</div>
</div>
</div>
<!-- Opérateur -->
<div class="col-md-6">
<div class="mb-2">
<small class="text-muted d-block">Opérateur ID</small>
<div class="d-flex align-items-center">
<ng-icon name="lucideBuilding" class="me-2 text-muted"></ng-icon>
<span class="fw-medium">
{{ config.operatorId || 'Non spécifié' }}
</span>
</div>
</div>
</div>
<!-- Valeur -->
<div class="col-12">
<div class="mb-2">
<small class="text-muted d-block">Valeur</small>
<div class="p-3 bg-light rounded border">
<pre class="mb-0" style="white-space: pre-wrap; word-break: break-all;">
{{ config.value || 'Aucune valeur' }}
</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
}
</div>
<!-- CONTACTS TECHNIQUES (READONLY) -->
<div class="row g-3 mt-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center border-bottom pb-2 mb-3">
<h6 class="mb-0 text-primary">
<ng-icon name="lucideUsers" class="me-2"></ng-icon>
Contacts Techniques
</h6>
<span class="badge bg-secondary">{{ selectedMerchantForEdit.technicalContacts.length || 0 }} contact(s)</span>
</div>
</div>
@if (!selectedMerchantForEdit.technicalContacts || selectedMerchantForEdit.technicalContacts.length === 0) {
<div class="col-12">
<div class="alert alert-warning">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Aucun contact technique défini
</div>
</div>
}
<!-- Liste des contacts techniques en mode lecture -->
@for (contact of selectedMerchantForEdit.technicalContacts; track contact.id || $index; let i = $index) {
<div class="col-12 col-md-6 col-lg-4">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-light py-2">
<div class="d-flex align-items-center">
<ng-icon name="lucideUser" class="me-2 text-primary"></ng-icon>
<span class="fw-semibold">Contact {{ i + 1 }}</span>
</div>
</div>
<div class="card-body">
<!-- Nom complet -->
<div class="mb-3">
<small class="text-muted d-block">Nom complet</small>
<div class="d-flex align-items-center">
<ng-icon name="lucideUser" class="me-2 text-muted"></ng-icon>
<span class="fw-medium">
{{ contact.firstName || '' }} {{ contact.lastName || '' }}
@if (!contact.firstName && !contact.lastName) {
<span class="text-muted fst-italic">Non spécifié</span>
}
</span>
</div>
</div>
<!-- Téléphone -->
<div class="mb-3">
<small class="text-muted d-block">Téléphone</small>
<div class="d-flex align-items-center">
<ng-icon name="lucidePhone" class="me-2 text-muted"></ng-icon>
@if (contact.phone) {
<a href="tel:{{ contact.phone }}" class="text-decoration-none">
{{ contact.phone }}
</a>
} @else {
<span class="text-muted fst-italic">Non spécifié</span>
}
</div>
</div>
<!-- Email -->
<div class="mb-3">
<small class="text-muted d-block">Email</small>
<div class="d-flex align-items-center">
<ng-icon name="lucideMail" class="me-2 text-muted"></ng-icon>
@if (contact.email) {
<a href="mailto:{{ contact.email }}" class="text-decoration-none">
{{ contact.email }}
</a>
} @else {
<span class="text-muted fst-italic">Non spécifié</span>
}
</div>
</div>
@ -673,114 +892,6 @@
</div>
}
</div>
<!-- CONTACTS TECHNIQUES -->
<div class="row g-3">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center border-bottom pb-2">
<h6 class="mb-0 text-primary">
<ng-icon name="lucideUsers" class="me-2"></ng-icon>
Contacts Techniques
</h6>
<button
type="button"
class="btn btn-outline-primary btn-sm"
(click)="addTechnicalContactInEdit()"
[disabled]="updatingMerchant"
>
<ng-icon name="lucideUserPlus" class="me-1"></ng-icon>
Ajouter un contact
</button>
</div>
</div>
@if (!selectedMerchantForEdit.technicalContacts || selectedMerchantForEdit.technicalContacts.length === 0) {
<div class="col-12">
<div class="alert alert-warning">
<ng-icon name="lucideAlertCircle" class="me-2"></ng-icon>
Au moins un contact technique est requis
</div>
</div>
}
<!-- Liste des contacts techniques -->
@for (contact of selectedMerchantForEdit.technicalContacts; track trackByContactId($index, contact); let i = $index) {
<div class="col-12">
<div class="card border-0 shadow-sm">
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<ng-icon name="lucideUser" class="me-2 text-primary"></ng-icon>
<span class="fw-semibold">Contact {{ i + 1 }}</span>
</div>
@if (selectedMerchantForEdit.technicalContacts.length > 1) {
<button
type="button"
class="btn btn-sm btn-outline-danger"
(click)="removeTechnicalContactInEdit(i)"
[disabled]="updatingMerchant"
>
<ng-icon name="lucideTrash2" class="me-1"></ng-icon>
Supprimer
</button>
}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label">Prénom <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.firstName"
[name]="'editFirstName_' + i"
required
[disabled]="updatingMerchant"
placeholder="Prénom"
>
</div>
<div class="col-md-6">
<label class="form-label">Nom <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.lastName"
[name]="'editLastName_' + i"
required
[disabled]="updatingMerchant"
placeholder="Nom"
>
</div>
<div class="col-md-6">
<label class="form-label">Téléphone <span class="text-danger">*</span></label>
<input
type="text"
class="form-control"
[(ngModel)]="contact.phone"
[name]="'editPhone_' + i"
required
[disabled]="updatingMerchant"
placeholder="+XX X XX XX XX XX"
>
</div>
<div class="col-md-6">
<label class="form-label">Email <span class="text-danger">*</span></label>
<input
type="email"
class="form-control"
[(ngModel)]="contact.email"
[name]="'editEmail_' + i"
required
[disabled]="updatingMerchant"
placeholder="email@exemple.com"
>
</div>
</div>
</div>
</div>
</div>
}
</div>
<div class="modal-footer mt-4 border-top pt-3">
<button
type="button"

View File

@ -28,7 +28,6 @@ import { MerchantDataAdapter } from './merchant-data-adapter.service';
import { SearchUsersParams } from '@core/models/dcb-bo-hub-user.model';
import { MerchantUsersService } from '../hub-users-management/merchant-users.service';
import { User, UserType } from '@core/models/dcb-bo-hub-user.model';
@Injectable({ providedIn: 'root' })
export class MerchantConfigService {
@ -62,205 +61,47 @@ export class MerchantConfigService {
);
}
getMerchants(page: number = 1, limit: number = 10, params?: SearchMerchantsParams): Observable<PaginatedResponse<Merchant>> {
// Vérifier si le cache est valide
const paramsChanged = !this.areParamsEqual(params, this.cacheParams);
/**
* Récupère tous les merchants (optionnel: avec recherche)
*/
getAllMerchants(
page: number = 1,
limit: number = 10,
params?: SearchMerchantsParams
): Observable<PaginatedResponse<Merchant>> {
const skip = (page - 1) * limit;
if (paramsChanged || this.merchantsCache.length === 0) {
// Nouvelle requête nécessaire
return this.fetchAllMerchants(params).pipe(
map(allMerchants => {
// Mettre en cache
this.merchantsCache = allMerchants;
this.cacheParams = params;
// Appliquer pagination
return this.applyPagination(allMerchants, page, limit);
})
);
} else {
// Vérifier si le cache contient assez d'éléments pour la page demandée
const neededItems = page * limit;
if (neededItems <= this.merchantsCache.length) {
// Cache suffisant
return of(this.applyPagination(this.merchantsCache, page, limit));
} else {
// Besoin de plus d'éléments
const additionalNeeded = neededItems - this.merchantsCache.length;
const newTake = this.calculateOptimalTake(additionalNeeded, page, limit);
console.log(`🔄 Cache insufficient (${this.merchantsCache.length}), fetching ${newTake} more items`);
return this.fetchAdditionalMerchants(newTake, params).pipe(
map(additionalMerchants => {
// Fusionner avec le cache (éviter les doublons)
const merged = this.mergeMerchants(this.merchantsCache, additionalMerchants);
this.merchantsCache = merged;
return this.applyPagination(merged, page, limit);
})
);
}
}
}
// Calculer le take optimal en fonction du total
private calculateOptimalTake(totalCount: number, page: number, limit: number): number {
// Calculer combien d'éléments nous avons besoin pour la pagination
const neededItems = page * limit;
// Ajouter un buffer pour éviter les appels fréquents
const buffer = Math.max(limit * 2, 100);
// Calculer le take nécessaire
let optimalTake = neededItems + buffer;
// Si le total est connu, adapter le take
if (totalCount > 0) {
// Prendre soit ce dont on a besoin, soit le total (le plus petit)
optimalTake = Math.min(optimalTake, totalCount);
}
// Arrondir aux paliers optimaux
return this.roundToOptimalValue(optimalTake);
}
// Arrondir à des valeurs optimales (100, 500, 1000, etc.)
private roundToOptimalValue(value: number): number {
if (value <= 100) return 100;
if (value <= 500) return 500;
if (value <= 1000) return 1000;
if (value <= 2000) return 2000;
if (value <= 5000) return 5000;
if (value <= 10000) return 10000;
// Pour les très grands nombres, arrondir au multiple de 10000 supérieur
return Math.ceil(value / 10000) * 10000;
}
private fetchAllMerchants(params?: SearchMerchantsParams): Observable<Merchant[]> {
// Commencer avec un take raisonnable
const initialTake = 500;
return this.fetchMerchantsWithParams(initialTake, params).pipe(
switchMap(initialBatch => {
// Si nous avons récupéré moins que demandé, c'est probablement tout
if (initialBatch.length < initialTake) {
console.log(`✅ Retrieved all ${initialBatch.length} merchants`);
return of(initialBatch);
}
// Sinon, peut-être qu'il y a plus, essayer un take plus grand
console.log(`⚠️ Initial batch size (${initialBatch.length}) equals take, might be more`);
const largerTake = 2000;
return this.fetchMerchantsWithParams(largerTake, params).pipe(
map(largerBatch => {
console.log(`✅ Retrieved ${largerBatch.length} merchants with larger take`);
return largerBatch;
})
);
})
);
}
private fetchMerchantsWithParams(take: number, params?: SearchMerchantsParams): Observable<Merchant[]> {
let httpParams = new HttpParams().set('take', take.toString());
if (params?.query) {
httpParams = httpParams.set('query', params.query.trim());
}
console.log(`📥 Fetching ${take} merchants`);
return this.http.get<ApiMerchant[]>(this.baseApiUrl, {
params: httpParams
}).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiMerchants =>
apiMerchants.map(merchant =>
this.dataAdapter.convertApiMerchantToFrontend(merchant)
)
)
);
}
private fetchAdditionalMerchants(take: number, params?: SearchMerchantsParams): Observable<Merchant[]> {
// Prendre à partir de la fin du cache
const skip = this.merchantsCache.length;
let httpParams = new HttpParams()
.set('take', take.toString())
.set('skip', skip.toString()); // Si votre API supporte skip
.set('skip', skip.toString())
.set('take', limit.toString());
if (params?.query) {
httpParams = httpParams.set('query', params.query.trim());
}
console.log(`📥 Fetching additional ${take} merchants (skip: ${skip})`);
return this.http.get<{ items: ApiMerchant[], total: number }>(this.baseApiUrl, { params: httpParams })
.pipe(
timeout(this.REQUEST_TIMEOUT),
map(response => {
// Sécuriser le mapping, même si items est undefined
const itemsArray: ApiMerchant[] = Array.isArray(response?.items) ? response.items : [];
const merchants: Merchant[] = itemsArray.map(m => this.dataAdapter.convertApiMerchantToFrontend(m));
return this.http.get<ApiMerchant[]>(this.baseApiUrl, {
params: httpParams
}).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiMerchants =>
apiMerchants.map(merchant =>
this.dataAdapter.convertApiMerchantToFrontend(merchant)
)
)
);
}
const total: number = typeof response?.total === 'number' ? response.total : merchants.length;
private mergeMerchants(existing: Merchant[], newOnes: Merchant[]): Merchant[] {
const existingIds = new Set(existing.map(m => m.id));
const uniqueNewOnes = newOnes.filter(m => !existingIds.has(m.id));
return [...existing, ...uniqueNewOnes];
}
private applyPagination(merchants: Merchant[], page: number, limit: number): PaginatedResponse<Merchant> {
const total = merchants.length;
const totalPages = Math.ceil(total / limit);
const startIndex = (page - 1) * limit;
const endIndex = Math.min(startIndex + limit, total);
const paginatedItems = merchants.slice(startIndex, endIndex);
return {
items: paginatedItems,
total: total,
page: page,
limit: limit,
totalPages: totalPages
};
}
private areParamsEqual(a?: SearchMerchantsParams, b?: SearchMerchantsParams): boolean {
if (!a && !b) return true;
if (!a || !b) return false;
return a.query === b.query;
}
getAllMerchants(params?: SearchMerchantsParams): Observable<Merchant[]> {
let httpParams = new HttpParams();
if (params?.query) {
httpParams = httpParams.set('query', params.query.trim());
}
return this.http.get<ApiMerchant[]>(this.baseApiUrl, { params: httpParams }).pipe(
timeout(this.REQUEST_TIMEOUT),
map(apiMerchants =>
apiMerchants.map(merchant =>
this.dataAdapter.convertApiMerchantToFrontend(merchant)
)
),
catchError(error => this.handleError('getAllMerchants', error))
);
return {
items: merchants,
total,
page,
limit,
totalPages: Math.ceil(total / limit)
};
}),
catchError(error => this.handleError('getAllMerchants', error))
);
}
getMerchantById(userId: number): Observable<Merchant> {
//const numericId = this.convertIdToNumber(id);
console.log(`📥 Loading merchant ${userId}`);
return this.http.get<ApiMerchant>(`${this.baseApiUrl}/${userId}`).pipe(
@ -275,8 +116,6 @@ export class MerchantConfigService {
}
updateMerchant(id: number, updateMerchantDto: UpdateMerchantDto): Observable<Merchant> {
//const numericId = this.convertIdToNumber(id);
const apiDto = this.dataAdapter.convertUpdateMerchantToApi(updateMerchantDto);
console.log(`📤 Updating merchant ${id}:`, apiDto);

View File

@ -3,16 +3,18 @@ import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule, FormBuilder, Validators, FormArray, FormGroup } from '@angular/forms';
import { NgIcon } from '@ng-icons/core';
import { NgbNavModule, NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap';
import { catchError, finalize, map, of, Subject, takeUntil } from 'rxjs';
import { catchError, finalize, map, Observable, of, Subject, switchMap, takeUntil, tap } from 'rxjs';
import { DomSanitizer, SafeUrl } from '@angular/platform-browser';
import { MerchantConfigService } from './merchant-config.service';
import { RoleManagementService } from '@core/services/hub-users-roles-management.service';
import { AuthService } from '@core/services/auth.service';
import { MerchantSyncService } from '../hub-users-management/merchant-sync-orchestrator.service';
import { PageTitle } from '@app/components/page-title/page-title';
import { MerchantConfigsList } from './merchant-config-list/merchant-config-list';
import { MerchantConfigView } from './merchant-config-view/merchant-config-view';
import { MinioService } from '@core/services/minio.service';
import {
CreateMerchantDto,
MerchantUtils,
@ -23,7 +25,7 @@ import {
MerchantConfig,
TechnicalContact
} from '@core/models/merchant-config.model';
import { UserRole, UserType, PaginatedUserResponse, User } from '@core/models/dcb-bo-hub-user.model';
import { UserRole, UserType, User } from '@core/models/dcb-bo-hub-user.model';
import { MerchantDataAdapter } from './merchant-data-adapter.service';
@ -47,12 +49,20 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
private modalService = inject(NgbModal);
private authService = inject(AuthService);
private merchantConfigService = inject(MerchantConfigService);
private merchantSyncService = inject(MerchantSyncService);
private dataAdapter = inject(MerchantDataAdapter);
protected roleService = inject(RoleManagementService);
private cdRef = inject(ChangeDetectorRef);
private destroy$ = new Subject<void>();
private minioService = inject(MinioService);
private sanitizer = inject(DomSanitizer);
// Cache des URLs de logos
private logoUrlCache = new Map<string, string>();
// Ajouter un cache pour les logos non trouvés
private logoErrorCache = new Set<string>();
// Configuration
readonly UserRole = UserRole;
readonly ConfigType = ConfigType;
@ -63,8 +73,22 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
pageSubtitle: string = 'Administrez les marchands et leurs configurations techniques';
badge: any = { icon: 'lucideSettings', text: 'Merchant Management' };
// ==================== GESTION DES LOGOS ====================
// Logo pour création
selectedLogoFile: File | null = null;
logoPreviewUrl: string | null = null;
uploadingLogo = false;
logoUploadError = '';
// Logo pour édition
editLogoFile: File | null = null;
editLogoPreviewUrl: string | null = null;
currentLogoUrl: string | null = null;
logoChanged = false;
// État de l'interface
activeTab: 'list' | 'merchant-profile' = 'list';
activeTab: 'list' | 'merchant-profile' = 'merchant-profile';
selectedMerchantId: number | null = null;
selectedConfigId: number | null = null;
selectedUserId: string | null = null;
@ -232,8 +256,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
} else {
console.warn('No merchant found for current user');
// Si aucun marchand trouvé, revenir à la liste
this.activeTab = 'list';
}
this.loadingUserMerchant = false;
@ -286,27 +308,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
}
// Gestion des contacts dans l'édition
addTechnicalContactInEdit(): void {
if (!this.selectedMerchantForEdit?.technicalContacts) {
this.selectedMerchantForEdit!.technicalContacts = [];
}
this.selectedMerchantForEdit?.technicalContacts.push({
firstName: '',
lastName: '',
phone: '',
email: ''
});
}
removeTechnicalContactInEdit(index: number): void {
if (this.selectedMerchantForEdit?.technicalContacts &&
this.selectedMerchantForEdit.technicalContacts.length > 1) {
this.selectedMerchantForEdit.technicalContacts.splice(index, 1);
}
}
/**
* Méthodes pour la gestion des configurations
*/
@ -328,25 +329,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
}
//Gestion des configs dans l'édition
addConfigInEdit(): void {
if (!this.selectedMerchantForEdit?.configs) {
this.selectedMerchantForEdit!.configs = [];
}
this.selectedMerchantForEdit?.configs.push({
name: ConfigType.API_KEY,
value: '',
operatorId: Operator.ORANGE_OSN
});
}
removeConfigInEdit(index: number): void {
if (this.selectedMerchantForEdit?.configs && this.selectedMerchantForEdit.configs.length > 1) {
this.selectedMerchantForEdit.configs.splice(index, 1);
}
}
// ==================== CONVERSION IDS ====================
/**
@ -364,8 +346,8 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
// Conversion pour la mise à jour
private convertUpdateMerchantToBackend(dto: UpdateMerchantDto, existingMerchant?: Merchant): any {
return this.dataAdapter.convertUpdateMerchantToApi(dto, existingMerchant);
private convertUpdateMerchantToBackend(dto: UpdateMerchantDto): any {
return this.dataAdapter.convertUpdateMerchantToApi(dto);
}
// ==================== GESTION DES PERMISSIONS ====================
@ -530,28 +512,24 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
return this.canManageMerchants;
}
private loadMerchantProfile(merchantId: number): void {
if (this.loadingMerchants[merchantId]) return;
this.loadingMerchants[merchantId] = true;
this.merchantConfigService.getMerchantById(merchantId).subscribe({
next: (merchant) => {
// Conversion pour Angular
const frontendMerchant = this.convertMerchantToFrontend(merchant);
this.merchantProfiles[merchantId] = frontendMerchant;
this.loadingMerchants[merchantId] = false;
},
error: (error) => {
console.error(`Error loading merchant profile ${merchantId}:`, error);
this.loadingMerchants[merchantId] = false;
}
});
}
backToList(): void {
console.log('🔙 Returning to list view');
this.showTab('list');
// Réinitialiser les IDs sélectionnés
this.selectedMerchantId = null;
this.selectedConfigId = null;
this.selectedUserId = null;
// Vider le cache du profil actuel
if (this.selectedMerchantId) {
delete this.merchantProfiles[this.selectedMerchantId];
}
// Changer d'onglet
this.activeTab = 'list';
// Forcer la détection des changements
this.cdRef.detectChanges();
}
@ -594,6 +572,7 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
}
this.resetMerchantForm();
this.removeSelectedLogo();
this.createMerchantError = '';
this.openModal(this.createMerchantModal);
}
@ -655,9 +634,101 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
});
}
/**
* Formate la taille du fichier
*/
formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
}
/**
* Vérifie si un logo existe et est valide
*/
hasValidLogo(merchant: Merchant): boolean {
return !!(merchant.logo && merchant.logo.trim().length > 0);
}
/**
* Nettoie les ressources au changement de marchand
*/
private cleanupLogoResources(): void {
// Nettoyer les URLs de prévisualisation
if (this.logoPreviewUrl && this.logoPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.logoPreviewUrl);
}
if (this.editLogoPreviewUrl && this.editLogoPreviewUrl.startsWith('blob:')) {
URL.revokeObjectURL(this.editLogoPreviewUrl);
}
this.logoPreviewUrl = null;
this.editLogoPreviewUrl = null;
this.selectedLogoFile = null;
this.editLogoFile = null;
}
// ==================== OPÉRATIONS CRUD ====================
// ==================== GESTION DU LOGO LORS DE LA CRÉATION ====================
/**
* Gère la sélection du logo lors de la création
*/
onLogoSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) {
return;
}
const file = input.files[0];
// Validation du fichier
const validation = this.minioService.validateImageFile(file);
if (!validation.valid) {
this.logoUploadError = validation.error || 'Fichier invalide';
this.selectedLogoFile = null;
this.logoPreviewUrl = null;
return;
}
this.selectedLogoFile = file;
this.logoUploadError = '';
// Générer la prévisualisation
this.minioService.previewImage(file).then(
(dataUrl) => {
this.logoPreviewUrl = dataUrl;
this.cdRef.detectChanges();
},
(error) => {
console.error('Erreur prévisualisation:', error);
this.logoUploadError = 'Impossible de prévisualiser l\'image';
}
);
}
/**
* Supprime le logo sélectionné pour la création
*/
removeSelectedLogo(): void {
this.selectedLogoFile = null;
this.logoPreviewUrl = null;
this.logoUploadError = '';
// Reset l'input file
const fileInput = document.getElementById('logoInput') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
}
/**
* Appel API pour créer le marchand
*/
createMerchant(): void {
if (!this.canCreateMerchants) {
this.createMerchantError = 'Vous n\'avez pas la permission de créer des marchands';
return;
@ -672,7 +743,6 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.creatingMerchant = true;
this.createMerchantError = '';
// Conversion pour l'API
const createDto = this.convertMerchantToBackend(this.newMerchant);
console.log('📤 Creating merchant:', createDto);
@ -681,13 +751,38 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (createdMerchant) => {
// Conversion de la réponse pour Angular
const frontendMerchant = this.convertMerchantToFrontend(createdMerchant);
// Si un logo est sélectionné, l'uploader d'abord
if (this.selectedLogoFile) {
this.uploadingLogo = true;
this.minioService.uploadMerchantLogo(Number(frontendMerchant.id), frontendMerchant.name, this.selectedLogoFile)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (uploadResponse) => {
console.log('✅ Logo uploaded:', uploadResponse);
this.uploadingLogo = false;
this.newMerchant.logo = uploadResponse.data.fileName;
},
error: (error) => {
console.error('❌ Error uploading logo:', error);
this.uploadingLogo = false;
}
});
}
console.log('✅ Merchant created successfully:', frontendMerchant);
this.creatingMerchant = false;
this.modalService.dismissAll();
this.refreshMerchantsList();
// Reset le formulaire et le logo
this.resetMerchantForm();
this.removeSelectedLogo();
this.cdRef.detectChanges();
},
error: (error) => {
@ -699,14 +794,69 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
});
}
// Mise à jour COMPLÈTE du merchant
// ==================== GESTION DU LOGO LORS DE L'ÉDITION ====================
/**
* Gère la sélection du nouveau logo lors de l'édition
*/
onEditLogoSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (!input.files || input.files.length === 0) {
return;
}
const file = input.files[0];
// Validation du fichier
const validation = this.minioService.validateImageFile(file);
if (!validation.valid) {
this.updateMerchantError = validation.error || 'Fichier invalide';
this.editLogoFile = null;
this.editLogoPreviewUrl = null;
return;
}
this.editLogoFile = file;
this.logoChanged = true;
this.updateMerchantError = '';
// Générer la prévisualisation
this.minioService.previewImage(file).then(
(dataUrl) => {
this.editLogoPreviewUrl = dataUrl;
this.cdRef.detectChanges();
},
(error) => {
console.error('Erreur prévisualisation:', error);
this.updateMerchantError = 'Impossible de prévisualiser l\'image';
}
);
}
/**
* Annule le changement de logo lors de l'édition
*/
cancelEditLogo(): void {
this.editLogoFile = null;
this.editLogoPreviewUrl = null;
this.logoChanged = false;
// Reset l'input file
const fileInput = document.getElementById('editLogoInput') as HTMLInputElement;
if (fileInput) {
fileInput.value = '';
}
}
/**
* Appel API pour mettre à jour le marchand
*/
updateMerchant(): void {
if (!this.selectedMerchantForEdit) {
this.updateMerchantError = 'Aucun marchand sélectionné pour modification';
return;
}
// Validation des données complètes
const validation = this.validateMerchantUpdate(this.selectedMerchantForEdit);
if (!validation.isValid) {
this.updateMerchantError = validation.errors.join(', ');
@ -716,45 +866,289 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
this.updatingMerchant = true;
this.updateMerchantError = '';
// Conversion pour l'API avec TOUTES les données
const merchantId = this.selectedMerchantForEdit.id!;
const updateDto = this.convertUpdateMerchantToBackend(this.selectedMerchantForEdit, this.selectedMerchantForEdit);
const merchantId = this.selectedMerchantForEdit!.id!;
const merchantName = this.selectedMerchantForEdit.name!;
console.log('📤 Updating merchant with full data:', updateDto);
let uploadObservable$;
this.merchantConfigService.updateMerchant(merchantId, updateDto)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (updatedMerchant) => {
// Conversion pour Angular
const frontendMerchant = this.convertMerchantToFrontend(updatedMerchant);
// Si un nouveau logo est sélectionné
if (this.editLogoFile && this.logoChanged) {
this.uploadingLogo = true;
this.updatingMerchant = false;
this.modalService.dismissAll();
this.refreshMerchantsConfigsView();
this.refreshMerchantsList();
uploadObservable$ = this.minioService.uploadMerchantLogo(
merchantId,
merchantName,
this.editLogoFile
).pipe(
switchMap(uploadResponse => {
console.log('✅ New logo uploaded:', uploadResponse);
this.uploadingLogo = false;
// Mettre à jour le cache
if (this.selectedMerchantId) {
this.merchantProfiles[this.selectedMerchantId] = frontendMerchant;
// Supprimer l'ancien logo si différent
const oldLogo = this.selectedMerchantForEdit!.logo;
if (oldLogo && oldLogo !== uploadResponse.data.fileName) {
this.logoUrlCache.delete(oldLogo);
this.minioService.deleteMerchantLogo(String(this.selectedMerchantForEdit?.id), oldLogo).subscribe({
next: () => console.log('🗑️ Old logo deleted'),
error: (err) => console.warn('⚠️ Could not delete old logo:', err)
});
}
// Mettre à jour le marchand de l'utilisateur si nécessaire
if (this.isMerchantUser && this.userMerchantId === merchantId) {
this.userMerchant = frontendMerchant;
// Mettre à jour le nom du logo dans l'objet merchant
this.selectedMerchantForEdit!.logo = uploadResponse.data.fileName;
console.log('Logo : ' + this.selectedMerchantForEdit!.logo)
// Retourner l'observable pour la mise à jour du marchand
const updateDto = this.convertUpdateMerchantToBackend(
this.selectedMerchantForEdit!
);
return this.merchantConfigService.updateMerchant(merchantId, updateDto);
})
);
} else {
// Pas de nouveau logo, mettre à jour directement
const updateDto = this.convertUpdateMerchantToBackend(
this.selectedMerchantForEdit!
);
uploadObservable$ = this.merchantConfigService.updateMerchant(merchantId, updateDto);
}
uploadObservable$.pipe(
takeUntil(this.destroy$)
).subscribe({
next: (updatedMerchant) => {
const frontendMerchant = this.convertMerchantToFrontend(updatedMerchant);
this.updatingMerchant = false;
this.modalService.dismissAll();
this.refreshMerchantsConfigsView();
this.refreshMerchantsList();
// Mettre à jour le cache
if (merchantId) {
this.merchantProfiles[merchantId] = frontendMerchant;
const cacheKey = `${merchantId}_${frontendMerchant.name}`;
// Invalider le cache de l'URL du logo
if (frontendMerchant.logo) {
this.logoUrlCache.set(cacheKey, frontendMerchant.logo);
}
this.successMessage = 'Marchand modifié avec succès';
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error updating merchant:', error);
this.updatingMerchant = false;
this.updateMerchantError = this.getUpdateErrorMessage(error);
this.cdRef.detectChanges();
}
});
if (this.isMerchantUser && this.userMerchantId === merchantId) {
this.userMerchant = frontendMerchant;
}
this.successMessage = 'Marchand modifié avec succès';
// Reset les états du logo
this.cancelEditLogo();
this.cdRef.detectChanges();
},
error: (error) => {
console.error('❌ Error in update process:', error);
this.updatingMerchant = false;
this.uploadingLogo = false;
this.updateMerchantError = this.getUpdateErrorMessage(error);
this.cdRef.detectChanges();
}
});
}
// ==================== AFFICHAGE DU LOGO ====================
/**
* Récupère l'URL du logo avec fallback automatique
*/
getMerchantLogoUrl(
merchantId: string,
merchantName: string,
logoFileName: string,
): Observable<string> {
const cacheKey = `${merchantId}_${merchantName}`;
// Vérifier si le logo est en cache d'erreur
if (this.logoErrorCache.has(cacheKey)) {
const defaultLogo = this.getDefaultLogoUrl(merchantName);
return of(defaultLogo);
}
// Vérifier le cache normal
if (this.logoUrlCache.has(cacheKey)) {
return of(this.logoUrlCache.get(cacheKey)!);
}
// Récupérer l'URL depuis l'API avec la nouvelle structure
return this.minioService.getMerchantLogoUrl(
merchantId,
logoFileName,
{ signed: true, expirySeconds: 3600 }
).pipe(
map(response => {
// Extraire l'URL de la réponse
const url = response.data.url ;
// Mettre en cache avec la clé composite
this.logoUrlCache.set(cacheKey, url);
return url;
}),
catchError(error => {
console.warn(`⚠️ Logo not found for merchant ${merchantId}: ${logoFileName}`, error);
// En cas d'erreur, ajouter au cache d'erreur
this.logoErrorCache.add(cacheKey);
// Générer un logo par défaut
const defaultLogo = this.getDefaultLogoUrl(merchantName);
// Mettre le logo par défaut dans le cache normal aussi
this.logoUrlCache.set(cacheKey, defaultLogo);
return of(defaultLogo);
})
);
}
/**
* Génère une URL de logo par défaut basée sur les initiales
*/
getDefaultLogoUrl(merchantName: string): string {
// Créer des initiales significatives
const initials = this.extractInitials(merchantName);
// Palette de couleurs agréables
const colors = [
'667eea', // Violet
'764ba2', // Violet foncé
'f56565', // Rouge
'4299e1', // Bleu
'48bb78', // Vert
'ed8936', // Orange
'FF6B6B', // Rouge clair
'4ECDC4', // Turquoise
'45B7D1', // Bleu clair
'96CEB4' // Vert menthe
];
const colorIndex = merchantName.length % colors.length;
const backgroundColor = colors[colorIndex];
// Taille fixe à 80px (l'API génère un carré de cette taille)
// L'image sera redimensionnée à 40px via CSS
return `https://ui-avatars.com/api/?name=${encodeURIComponent(initials)}&background=${backgroundColor}&color=FFFFFF&size=80`;
}
/**
* Extrait les initiales de manière intelligente
*/
private extractInitials(name: string): string {
if (!name || name.trim() === '') {
return '??';
}
// Nettoyer le nom
const cleanedName = name.trim().toUpperCase();
// Extraire les mots
const words = cleanedName.split(/\s+/);
// Si un seul mot, prendre les deux premières lettres
if (words.length === 1) {
return words[0].substring(0, 2) || '??';
}
// Prendre la première lettre des deux premiers mots
const initials = words
.slice(0, 2) // Prendre les 2 premiers mots
.map(word => word[0] || '')
.join('');
return initials || name.substring(0, 2).toUpperCase() || '??';
}
/**
* Gère les erreurs de chargement des logos MinIO
*/
onLogoError(event: Event, merchantName: string): void {
const img = event.target as HTMLImageElement;
if (!img) return;
console.warn('Logo MinIO failed to load, using default for:', merchantName);
img.onerror = null;
img.src = this.getDefaultLogoUrl(merchantName);
}
/**
* Gère les erreurs de chargement des logos par défaut
*/
onDefaultLogoError(event: Event | string): void {
if (!(event instanceof Event)) {
console.error('Default logo error (non-event):', event);
return;
}
const img = event.target as HTMLImageElement | null;
if (!img) return;
console.error('Default logo also failed to load, using fallback SVG');
// SVG local
img.onerror = null; // éviter boucle infinie
img.src = 'assets/images/default-merchant-logo.svg';
// Dernier recours
img.onerror = (e) => {
if (!(e instanceof Event)) return;
const fallbackImg = e.target as HTMLImageElement | null;
if (!fallbackImg) return;
fallbackImg.onerror = null;
fallbackImg.src = this.generateFallbackDataUrl();
};
}
/**
* Génère un fallback SVG en data URL
*/
private generateFallbackDataUrl(): string {
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40">
<rect width="40" height="40" fill="#667eea" rx="20"/>
<text x="20" y="22" text-anchor="middle" fill="white" font-family="Arial" font-size="14" font-weight="bold">?</text>
</svg>`;
return 'data:image/svg+xml;base64,' + btoa(svg);
}
/**
* Charge le logo pour l'édition
*/
loadMerchantLogoForEdit(merchant: Merchant): void {
if (!merchant.logo) {
this.currentLogoUrl = null;
return;
}
this.getMerchantLogoUrl(String(merchant.id), merchant.name, merchant.logo).subscribe({
next: (url) => {
this.currentLogoUrl = url;
this.cdRef.detectChanges();
},
error: (error) => {
console.error('Error loading logo for edit:', error);
this.currentLogoUrl = null;
}
});
}
// Validation complète pour la mise à jour
@ -774,57 +1168,12 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
errors.push('Le téléphone est requis');
}
// Validation des configurations
if (!merchant.configs || merchant.configs.length === 0) {
errors.push('Au moins une configuration est requise');
} else {
merchant.configs.forEach((config, index) => {
if (!config.name?.trim()) {
errors.push(`Le type de configuration ${index + 1} est requis`);
}
if (!config.value?.trim()) {
errors.push(`La valeur de configuration ${index + 1} est requise`);
}
if (!config.operatorId) {
errors.push(`L'opérateur de configuration ${index + 1} est requis`);
}
});
}
// Validation des contacts techniques
if (!merchant.technicalContacts || merchant.technicalContacts.length === 0) {
errors.push('Au moins un contact technique est requis');
} else {
merchant.technicalContacts.forEach((contact, index) => {
if (!contact.firstName?.trim()) {
errors.push(`Le prénom du contact ${index + 1} est requis`);
}
if (!contact.lastName?.trim()) {
errors.push(`Le nom du contact ${index + 1} est requis`);
}
if (!contact.phone?.trim()) {
errors.push(`Le téléphone du contact ${index + 1} est requis`);
}
if (!contact.email?.trim()) {
errors.push(`L'email du contact ${index + 1} est requis`);
} else if (!this.isValidEmail(contact.email)) {
errors.push(`L'email du contact ${index + 1} est invalide`);
}
});
}
return {
isValid: errors.length === 0,
errors: errors
};
}
// Validation d'email
private isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
confirmDeleteMerchant(): void {
if (!this.selectedMerchantForDelete) {
this.deleteMerchantError = 'Aucun marchand sélectionné pour suppression';
@ -891,17 +1240,28 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
};
}
private resetMerchantForm(): void {
this.newMerchant = this.getDefaultMerchantForm();
console.log('🔄 Merchant form reset');
}
/**
* Override de populateEditForm pour charger le logo
*/
private populateEditForm(merchant: Merchant): void {
this.selectedMerchantForEdit = {
...merchant,
configs: merchant.configs.map(config => ({ ...config })),
technicalContacts: merchant.technicalContacts.map(contact => ({ ...contact }))
};
// Charger le logo actuel
this.loadMerchantLogoForEdit(merchant);
this.editLogoFile = null;
this.editLogoPreviewUrl = null;
this.logoChanged = false;
}
private resetMerchantForm(): void {
this.newMerchant = this.getDefaultMerchantForm();
this.removeSelectedLogo();
console.log('🔄 Merchant form reset');
}
private refreshMerchantsList(): void {
@ -1025,8 +1385,14 @@ export class MerchantConfigManagement implements OnInit, OnDestroy {
return !this.isMerchantUser && this.activeTab === 'list';
}
// ==================== NETTOYAGE ====================
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
// Nettoyer les logos
this.cleanupLogoResources();
this.logoUrlCache.clear();
}
}

View File

@ -29,7 +29,7 @@ export class MerchantDataAdapter {
return {
...apiMerchant,
id: apiMerchant.id, //this.convertIdToString(apiMerchant.id),
id: apiMerchant.id,
configs: (apiMerchant.configs || []).map(config =>
this.convertApiConfigToFrontend(config)
),
@ -105,7 +105,7 @@ export class MerchantDataAdapter {
/**
* Convertit un DTO de mise à jour pour l'API
*/
convertUpdateMerchantToApi(dto: UpdateMerchantDto, existingMerchant?: Merchant): any {
convertUpdateMerchantToApi(dto: UpdateMerchantDto): any {
this.validateUpdateMerchantDto(dto);
const updateData: any = {};
@ -117,33 +117,6 @@ export class MerchantDataAdapter {
if (dto.adresse !== undefined) updateData.adresse = dto.adresse?.trim();
if (dto.phone !== undefined) updateData.phone = dto.phone?.trim();
// Configurations - seulement si présentes dans le DTO
if (dto.configs !== undefined) {
updateData.configs = (dto.configs || []).map(config => {
const apiConfig: any = {
name: config.name,
value: config.value?.trim(),
operatorId: this.validateOperatorId(config.operatorId)
};
return apiConfig;
});
}
// Contacts techniques - seulement si présents dans le DTO
if (dto.technicalContacts !== undefined) {
updateData.technicalContacts = (dto.technicalContacts || []).map(contact => {
const apiContact: any = {
firstName: contact.firstName?.trim(),
lastName: contact.lastName?.trim(),
phone: contact.phone?.trim(),
email: contact.email?.trim()
};
return apiContact;
});
}
return updateData;
}
@ -250,49 +223,6 @@ export class MerchantDataAdapter {
errors.push('Le téléphone est requis');
}
// Validation des configurations si présentes
if (dto.configs !== undefined) {
if (dto.configs.length === 0) {
errors.push('Au moins une configuration est requise');
} else {
dto.configs.forEach((config, index) => {
if (!config.name?.trim()) {
errors.push(`Le type de configuration ${index + 1} est requis`);
}
if (!config.value?.trim()) {
errors.push(`La valeur de configuration ${index + 1} est requise`);
}
if (!config.operatorId) {
errors.push(`L'opérateur de configuration ${index + 1} est requis`);
}
});
}
}
// Validation des contacts si présents
if (dto.technicalContacts !== undefined) {
if (dto.technicalContacts.length === 0) {
errors.push('Au moins un contact technique est requis');
} else {
dto.technicalContacts.forEach((contact, index) => {
if (!contact.firstName?.trim()) {
errors.push(`Le prénom du contact ${index + 1} est requis`);
}
if (!contact.lastName?.trim()) {
errors.push(`Le nom du contact ${index + 1} est requis`);
}
if (!contact.phone?.trim()) {
errors.push(`Le téléphone du contact ${index + 1} est requis`);
}
if (!contact.email?.trim()) {
errors.push(`L'email du contact ${index + 1} est requis`);
} else if (!this.isValidEmail(contact.email)) {
errors.push(`L'email du contact ${index + 1} est invalide`);
}
});
}
}
if (errors.length > 0) {
throw new Error(`Validation failed: ${errors.join(', ')}`);
}

View File

@ -7,16 +7,9 @@ import { MerchantUsersManagement } from '@modules/hub-users-management/merchant-
// Composants principaux
import { DcbReportingDashboard } from '@modules/dcb-dashboard/dcb-reporting-dashboard';
import { Team } from '@modules/team/team';
import { Transactions } from '@modules/transactions/transactions';
import { OperatorsConfig } from '@modules/operators/config/config';
import { OperatorsStats } from '@modules/operators/stats/stats';
import { WebhooksHistory } from '@modules/webhooks/history/history';
import { WebhooksStatus } from '@modules/webhooks/status/status';
import { WebhooksRetry } from '@modules/webhooks/retry/retry';
import { Settings } from '@modules/settings/settings';
import { Integrations } from '@modules/integrations/integrations';
import { Support } from '@modules/support/support';
import { MyProfile } from '@modules/profile/profile';
import { Documentation } from '@modules/documentation/documentation';
import { Help } from '@modules/help/help';
@ -39,18 +32,6 @@ const routes: Routes = [
}
},
// ---------------------------
// Team
// ---------------------------
{
path: 'team',
component: Team,
canActivate: [authGuard, roleGuard],
data: {
title: 'Team',
module: 'team'
}
},
// ---------------------------
// Transactions
@ -144,106 +125,10 @@ const routes: Routes = [
}
},
// ---------------------------
// Operators (Admin seulement)
// ---------------------------
{
path: 'operators',
canActivate: [authGuard, roleGuard],
data: { module: 'operators' },
children: [
{
path: 'config',
component: OperatorsConfig,
data: {
title: 'Paramètres d\'Intégration',
module: 'operators/config'
}
},
{
path: 'stats',
component: OperatorsStats,
data: {
title: 'Performance & Monitoring',
module: 'operators/stats'
}
},
]
},
// ---------------------------
// Webhooks
// Profile (Tous les utilisateurs authentifiés)
// ---------------------------
{
path: 'webhooks',
canActivate: [authGuard, roleGuard],
data: { module: 'webhooks' },
children: [
{
path: 'history',
component: WebhooksHistory,
data: {
title: 'Historique',
module: 'webhooks/history'
}
},
{
path: 'status',
component: WebhooksStatus,
data: {
title: 'Statut des Requêtes',
module: 'webhooks/status'
}
},
{
path: 'retry',
component: WebhooksRetry,
data: {
title: 'Relancer Webhook',
module: 'webhooks/retry'
}
},
]
},
// ---------------------------
// Settings
// ---------------------------
{
path: 'settings',
component: Settings,
canActivate: [authGuard, roleGuard],
data: {
title: 'Paramètres Système',
module: 'settings'
}
},
// ---------------------------
// Integrations (Admin seulement)
// ---------------------------
{
path: 'integrations',
component: Integrations,
canActivate: [authGuard, roleGuard],
data: {
title: 'Intégrations Externes',
module: 'integrations'
}
},
// ---------------------------
// Support & Profile (Tous les utilisateurs authentifiés)
// ---------------------------
{
path: 'support',
component: Support,
canActivate: [authGuard, roleGuard],
data: {
title: 'Support',
module: 'support'
}
},
{
path: 'profile',
component: MyProfile,

View File

@ -1 +0,0 @@
<p>Notifications - Actions</p>

View File

@ -1,2 +0,0 @@
import { NotificationsActions } from './actions';
describe('NotificationsActions', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-notifications-actions',
templateUrl: './actions.html',
})
export class NotificationsActions {}

View File

@ -1 +0,0 @@
<p>Notifications - Filters</p>

View File

@ -1,2 +0,0 @@
import { NotificationsFilters } from './filters';
describe('NotificationsFilters', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-notifications-filters',
templateUrl: './filters.html',
})
export class NotificationsFilters {}

View File

@ -1 +0,0 @@
<p>Notifications - List</p>

View File

@ -1,2 +0,0 @@
import { NotificationsList } from './list';
describe('NotificationsList', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-notifications-list',
templateUrl: './list.html',
})
export class NotificationsList {}

View File

@ -1 +0,0 @@
<p>Notifications</p>

View File

@ -1,2 +0,0 @@
import { Notifications } from './notifications';
describe('Notifications', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-notifications',
templateUrl: './notifications.html',
})
export class Notifications {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class NotificationsActionsService {
constructor() {}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class NotificationsFiltersService {
constructor() {}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class NotificationsListService {
constructor() {}
}

View File

@ -1,55 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
export interface Notification {
id: string;
type: 'SMS' | 'EMAIL' | 'PUSH' | 'SYSTEM';
title: string;
message: string;
recipient: string;
status: 'SENT' | 'DELIVERED' | 'FAILED' | 'PENDING';
createdAt: Date;
sentAt?: Date;
errorMessage?: string;
}
export interface NotificationFilter {
type?: string;
status?: string;
startDate?: Date;
endDate?: Date;
recipient?: string;
}
@Injectable({ providedIn: 'root' })
export class NotificationService {
private http = inject(HttpClient);
private apiUrl = `${environment.localServiceTestApiUrl}/notifications`;
getNotifications(filters?: NotificationFilter): Observable<Notification[]> {
return this.http.post<Notification[]>(
`${this.apiUrl}/list`,
filters
);
}
sendNotification(notification: Partial<Notification>): Observable<Notification> {
return this.http.post<Notification>(
`${this.apiUrl}/send`,
notification
);
}
getNotificationStats(): Observable<any> {
return this.http.get(`${this.apiUrl}/stats`);
}
retryNotification(notificationId: string): Observable<Notification> {
return this.http.post<Notification>(
`${this.apiUrl}/${notificationId}/retry`,
{}
);
}
}

View File

@ -1 +0,0 @@
<p>Operators - Config</p>

View File

@ -1,2 +0,0 @@
import { OperatorsConfig } from './config';
describe('OperatorsConfig', () => {});

View File

@ -1,36 +0,0 @@
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { UiCard } from '@app/components/ui-card';
import { InputFields } from '@/app/modules/components/input-fields';
import { CheckboxesAndRadios } from '@/app/modules/components/checkboxes-and-radios';
import { InputTouchspin } from '@/app/modules/components/input-touchspin';
@Component({
selector: 'app-operator-config',
//imports: [FormsModule, UiCard, InputFields, CheckboxesAndRadios, InputTouchspin],
templateUrl: './config.html',
})
export class OperatorsConfig {
operatorConfig = {
name: '',
apiEndpoint: '',
apiKey: '',
secretKey: '',
timeout: 30,
retryAttempts: 3,
webhookUrl: '',
isActive: true,
supportedCountries: [] as string[],
supportedServices: ['DCB', 'SMS', 'USSD']
};
countries = ['CIV', 'SEN', 'CMR', 'COD', 'TUN', 'BFA', 'MLI', 'GIN'];
saveConfig() {
console.log('Saving operator config:', this.operatorConfig);
}
testConnection() {
console.log('Testing connection to:', this.operatorConfig.apiEndpoint);
}
}

View File

@ -1 +0,0 @@
<p>Operators</p>

View File

@ -1,2 +0,0 @@
import { Operators } from './operators';
describe('Operators', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-operators',
templateUrl: './operators.html',
})
export class Operators {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class OperatorsConfigService {
constructor() {}
}

View File

@ -1,47 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
export interface Operator {
id: string;
name: string;
country: string;
status: 'ACTIVE' | 'INACTIVE';
config: OperatorConfig;
}
export interface OperatorConfig {
apiEndpoint: string;
apiKey: string;
secretKey: string;
timeout: number;
retryAttempts: number;
webhookUrl: string;
isActive: boolean;
supportedServices: string[];
}
@Injectable({ providedIn: 'root' })
export class OperatorService {
private http = inject(HttpClient);
private apiUrl = `${environment.localServiceTestApiUrl}/operators`;
getOperators(): Observable<Operator[]> {
return this.http.get<Operator[]>(`${this.apiUrl}`);
}
updateOperatorConfig(operatorId: string, config: OperatorConfig): Observable<Operator> {
return this.http.put<Operator>(
`${this.apiUrl}/${operatorId}/config`,
config
);
}
testConnection(operatorId: string): Observable<{ success: boolean; latency: number }> {
return this.http.post<{ success: boolean; latency: number }>(
`${this.apiUrl}/${operatorId}/test-connection`,
{}
);
}
}

View File

@ -1,44 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
export interface OperatorStats {
operatorId: string;
totalTransactions: number;
successRate: number;
totalRevenue: number;
averageLatency: number;
errorCount: number;
uptime: number;
dailyStats: DailyStat[];
}
export interface DailyStat {
date: string;
transactions: number;
successRate: number;
revenue: number;
}
@Injectable({ providedIn: 'root' })
export class OperatorStatsService {
private http = inject(HttpClient);
private apiUrl = `${environment.localServiceTestApiUrl}/operators`;
getOperatorStats(operatorId: string): Observable<OperatorStats> {
return this.http.get<OperatorStats>(
`${this.apiUrl}/${operatorId}/stats`
);
}
getOperatorsComparison(): Observable<any[]> {
return this.http.get<any[]>(`${this.apiUrl}/comparison`);
}
getPerformanceMetrics(operatorId: string, period: string): Observable<any> {
return this.http.get(
`${this.apiUrl}/${operatorId}/metrics?period=${period}`
);
}
}

View File

@ -1 +0,0 @@
<p>Operators - Stats</p>

View File

@ -1,2 +0,0 @@
import { OperatorsStats } from './stats';
describe('OperatorsStats', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-operators-stats',
templateUrl: './stats.html',
})
export class OperatorsStats {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class SettingsService {
constructor() {}
}

View File

@ -1 +0,0 @@
<p>Settings</p>

View File

@ -1,15 +0,0 @@
import { Routes } from '@angular/router';
import { Settings } from './settings';
import { authGuard } from '../../core/guards/auth.guard';
import { roleGuard } from '../../core/guards/role.guard';
export const SETTINGS_ROUTES: Routes = [
{
path: 'settings',
canActivate: [authGuard, roleGuard],
component: Settings,
data: {
title: 'Configuration',
}
}
];

View File

@ -1,2 +0,0 @@
import { Settings } from './settings';
describe('Settings', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-settings',
templateUrl: './settings.html',
})
export class Settings {}

View File

@ -1,628 +0,0 @@
import { Component, OnInit } from '@angular/core';
import { MerchantSyncService } from '../hub-users-management/merchant-sync-orchestrator.service';
import { UserRole } from '@core/models/dcb-bo-hub-user.model';
import { firstValueFrom } from 'rxjs';
import { MerchantUsersService } from '@modules/hub-users-management/merchant-users.service';
@Component({
selector: 'app-support',
templateUrl: './support.html'
})
export class Support implements OnInit {
private testData = {
merchantConfigId: '',
keycloakUserId: '',
testUserId: '',
testMerchantConfigUserId: '',
associatedUserId: '' // ID d'un utilisateur associé au marchand
};
constructor(
private merchantCrudService: MerchantSyncService,
private merchantUsersService: MerchantUsersService
) {}
ngOnInit() {
console.log('🚀 Support Component - Tests CRUD Merchants');
console.log('='.repeat(60));
console.log('📌 NOUVELLE LOGIQUE: Merchant et User indépendants, association séparée');
console.log('='.repeat(60));
// Démarrer les tests automatiquement
this.runAllTests();
}
/**
* Exécuter tous les tests
*/
async runAllTests(): Promise<void> {
try {
console.log('\n🧪 DÉMARRAGE DES TESTS COMPLETS');
console.log('📌 NOUVELLE LOGIQUE: Association séparée');
console.log('='.repeat(50));
// Test 1: Création indépendante
await this.testCreateOperations();
// Test 2: Association
await this.testAssociationOperations();
// Test 3: Lecture
await this.testReadOperations();
// Test 4: Mise à jour
await this.testUpdateOperations();
// Test 5: Gestion utilisateurs
await this.testUserOperations();
// Test 6: Suppression
await this.testDeleteOperations();
console.log('\n✅ TOUS LES TESTS TERMINÉS AVEC SUCCÈS!');
console.log('='.repeat(50));
} catch (error) {
console.error('❌ ERREUR CRITIQUE DANS LES TESTS:', error);
}
}
/**
* TEST 1: Opérations de création indépendante
*/
private async testCreateOperations(): Promise<void> {
console.log('\n📝 TEST 1: Opérations CREATE INDÉPENDANTES');
console.log('-'.repeat(40));
try {
// 1.1 Créer un merchant uniquement dans MerchantConfig
console.log('1.1 Création d\'un merchant dans MerchantConfig seulement...');
const merchantData = {
name: 'Test Merchant ' + Date.now(),
adresse: '123 Test Street',
phone: '+336' + Math.floor(10000000 + Math.random() * 90000000),
configs: [
{
name: 'API_KEY',
value: 'test-api-key-' + Date.now(),
operatorId: 1
}
],
technicalContacts: [
{
firstName: 'Test',
lastName: 'User',
phone: '+33612345678',
email: `test.${Date.now()}@example.com`
}
]
};
console.log('📤 Données merchant pour MerchantConfig:', merchantData);
const merchantConfigResult = await firstValueFrom(
this.merchantCrudService.createMerchantInConfigOnly(merchantData)
);
this.testData.merchantConfigId = String(merchantConfigResult.id!);
console.log('✅ Merchant créé dans MerchantConfig uniquement!');
console.log(' Merchant Config ID:', this.testData.merchantConfigId);
console.log(' Merchant name:', merchantConfigResult.name);
// 1.2 Créer un utilisateur Hub dans Keycloak
console.log('\n1.2 Création d\'un utilisateur Hub dans Keycloak...');
const hubUserData = {
username: `testhubuser.${Date.now()}`,
email: `hubuser.${Date.now()}@example.com`,
password: 'HubPassword123!',
firstName: 'Test',
lastName: 'HubUser',
role: UserRole.DCB_SUPPORT
};
console.log('👤 Données Hub User pour Keycloak:', { ...hubUserData, password: '***' });
const hubUserResult = await firstValueFrom(
this.merchantCrudService.createKeycloakUser(hubUserData)
);
console.log('✅ Utilisateur Hub créé dans Keycloak:');
console.log(' Keycloak User ID:', hubUserResult.id);
console.log(' Email:', hubUserResult.email);
console.log(' Rôle:', hubUserResult.role);
console.log(' User Type:', hubUserResult.userType);
// 1.3 Créer un utilisateur Partenaire dans Keycloak
console.log('\n1.3 Création d\'un utilisateur Partenaire dans Keycloak...');
const partnerUserData = {
username: `testpartner.${Date.now()}`,
email: `partner.${Date.now()}@example.com`,
password: 'PartnerPassword123!',
firstName: 'Test',
lastName: 'Partner',
role: UserRole.DCB_PARTNER_ADMIN
};
console.log('👤 Données Partner User pour Keycloak:', { ...partnerUserData, password: '***' });
const partnerUserResult = await firstValueFrom(
this.merchantCrudService.createKeycloakUser(partnerUserData)
);
this.testData.testUserId = partnerUserResult.id;
console.log('✅ Utilisateur Partenaire créé dans Keycloak:');
console.log(' Keycloak User ID:', this.testData.testUserId);
console.log(' Email:', partnerUserResult.email);
console.log(' Rôle:', partnerUserResult.role);
console.log(' User Type:', partnerUserResult.userType);
} catch (error) {
console.error('❌ ERREUR lors de la création:', error);
throw error;
}
}
/**
* TEST 2: Opérations d'association
*/
private async testAssociationOperations(): Promise<void> {
console.log('\n🔗 TEST 2: Opérations d\'ASSOCIATION');
console.log('-'.repeat(40));
if (!this.testData.merchantConfigId || !this.testData.testUserId) {
console.log('⚠️ Merchant ou utilisateur non créé, passage au test suivant');
return;
}
try {
// 2.1 Associer l'utilisateur au marchand
console.log('2.1 Association de l\'utilisateur au marchand...');
const associationData = {
userId: this.testData.testUserId,
merchantConfigId: this.testData.merchantConfigId,
role: UserRole.MERCHANT_CONFIG_ADMIN
};
console.log('🔗 Données d\'association:', associationData);
const associationResult = await firstValueFrom(
this.merchantCrudService.associateUserToMerchant(associationData)
);
this.testData.associatedUserId = associationResult.keycloakUser.id;
this.testData.testMerchantConfigUserId = String(associationResult.merchantConfigUser?.userId || '');
console.log('✅ Utilisateur associé au marchand:');
console.log(' Keycloak User ID:', associationResult.keycloakUser.id);
console.log(' Merchant Config User ID:', this.testData.testMerchantConfigUserId);
console.log(' Rôle dans MerchantConfig:', associationResult.merchantConfigUser?.role);
console.log(' Associé au merchant:', this.testData.merchantConfigId);
// 2.2 Récupérer les utilisateurs associés au marchand
console.log('\n2.2 Lecture des utilisateurs associés au marchand...');
const merchantUsers = await firstValueFrom(
this.merchantCrudService.getUsersByMerchant(this.testData.merchantConfigId)
);
console.log(`${merchantUsers.length} utilisateurs associés à ce merchant`);
merchantUsers.forEach((user: any, index: number) => {
console.log(` ${index + 1}. ${user.email || 'Inconnu'}`);
console.log(` ID: ${user.id}`);
console.log(` Rôle: ${user.role}`);
});
} catch (error) {
console.error('❌ ERREUR lors de l\'association:', error);
}
}
/**
* TEST 3: Opérations de lecture
*/
private async testReadOperations(): Promise<void> {
console.log('\n🔍 TEST 3: Opérations READ');
console.log('-'.repeat(40));
if (!this.testData.merchantConfigId) {
console.log('⚠️ Aucun merchant créé, passage au test suivant');
return;
}
try {
// 3.1 Lire le merchant depuis MerchantConfig
console.log('3.1 Lecture du merchant depuis MerchantConfig...');
const merchant = await firstValueFrom(
this.merchantCrudService.getMerchantFromConfigOnly(
this.testData.merchantConfigId
)
);
console.log('✅ Merchant récupéré depuis MerchantConfig:');
console.log(' ID:', merchant.id);
console.log(' Nom:', merchant.name);
console.log(' Adresse:', merchant.adresse);
console.log(' Configurations:', merchant.configs?.length || 0);
// 3.2 Lire tous les merchants depuis MerchantConfig
console.log('\n3.2 Lecture de tous les merchants depuis MerchantConfig...');
const allMerchants = await firstValueFrom(
this.merchantCrudService.getAllMerchantsFromConfig()
);
console.log(`${allMerchants.length} merchants trouvés au total`);
if (allMerchants.length > 0) {
const lastMerchant = allMerchants[allMerchants.length - 1];
console.log(' Dernier merchant:', lastMerchant.name, '(ID:', lastMerchant.id, ')');
}
// 3.3 Rechercher des merchants
console.log('\n3.3 Recherche de merchants dans MerchantConfig...');
const searchResults = await firstValueFrom(
this.merchantCrudService.searchMerchantsInConfig('Test')
);
console.log(`${searchResults.length} merchants trouvés avec "Test"`);
if (searchResults.length > 0) {
console.log(' Premier résultat:', searchResults[0].name);
}
// 3.4 Rechercher des utilisateurs
console.log('\n3.4 Recherche d\'utilisateurs dans Keycloak...');
const userSearchResults = await firstValueFrom(
this.merchantCrudService.searchKeycloakUsers('test')
);
console.log(`${userSearchResults.length} utilisateurs trouvés avec "test"`);
if (userSearchResults.length > 0) {
console.log(' Premier résultat:', userSearchResults[0].email);
}
} catch (error) {
console.error('❌ ERREUR lors de la lecture:', error);
}
}
/**
* TEST 4: Opérations de mise à jour
*/
private async testUpdateOperations(): Promise<void> {
console.log('\n✏ TEST 4: Opérations UPDATE');
console.log('-'.repeat(40));
if (!this.testData.merchantConfigId) {
console.log('⚠️ Aucun merchant créé, passage au test suivant');
return;
}
try {
// 4.1 Mettre à jour le merchant dans MerchantConfig
console.log('4.1 Mise à jour du merchant dans MerchantConfig...');
const newName = `Updated Merchant ${Date.now()}`;
const updateResult = await firstValueFrom(
this.merchantCrudService.updateMerchantInConfigOnly(
this.testData.merchantConfigId,
{
name: newName,
description: 'Mis à jour par les tests',
adresse: '456 Updated Street'
}
)
);
console.log('✅ Merchant mis à jour dans MerchantConfig:');
console.log(' Nouveau nom:', newName);
console.log(' Description:', updateResult.description);
// 4.3 Changer le rôle de l'utilisateur dans MerchantConfig
console.log('\n4.3 Changement de rôle utilisateur dans MerchantConfig...');
if (this.testData.testUserId && this.testData.merchantConfigId && this.testData.testMerchantConfigUserId) {
const roleUpdateResult = await firstValueFrom(
this.merchantCrudService.updateUserRoleInMerchantConfig(
this.testData.merchantConfigId,
this.testData.testUserId,
UserRole.MERCHANT_CONFIG_MANAGER
)
);
console.log('✅ Rôle utilisateur mis à jour dans MerchantConfig:');
console.log(' Nouveau rôle:', UserRole.MERCHANT_CONFIG_MANAGER);
}
} catch (error) {
console.error('❌ ERREUR lors de la mise à jour:', error);
}
}
/**
* TEST 5: Opérations utilisateurs avancées
*/
private async testUserOperations(): Promise<void> {
console.log('\n👥 TEST 5: Opérations utilisateurs avancées');
console.log('-'.repeat(40));
if (!this.testData.testUserId) {
console.log('⚠️ Aucun utilisateur créé, passage au test suivant');
return;
}
try {
// 5.1 Réinitialiser le mot de passe
console.log('5.1 Réinitialisation mot de passe...');
const resetPasswordDto = {
newPassword: 'NewPassword123!',
temporary: false
};
const resetResult = await firstValueFrom(
this.merchantUsersService.resetMerchantUserPassword(
this.testData.testUserId,
resetPasswordDto
)
);
console.log('✅ Mot de passe réinitialisé:');
console.log(' Message:', resetResult.message);
// 5.2 Dissocier l'utilisateur du marchand
console.log('\n5.2 Dissociation de l\'utilisateur du marchand...');
if (this.testData.testUserId && this.testData.merchantConfigId) {
const dissociateResult = await firstValueFrom(
this.merchantCrudService.dissociateUserFromMerchant(
this.testData.testUserId,
this.testData.merchantConfigId
)
);
console.log('✅ Utilisateur dissocié du marchand:');
console.log(' Succès:', dissociateResult.success);
console.log(' Message:', dissociateResult.message);
// Vérifier que l'utilisateur n'est plus associé
const userMerchants = await firstValueFrom(
this.merchantCrudService.getUsersByMerchant(this.testData.merchantConfigId)
);
const userStillLinked = userMerchants.some(
user => user.id === this.testData.testUserId
);
console.log(` Marchands associés après dissociation: ${userMerchants.length}`);
if (userStillLinked) {
console.error('❌ Lutilisateur est encore associé au marchand ! ❌');
} else {
console.log('✅ Lutilisateur a été correctement dissocié du marchand.');
}
}
// 5.3 Réassocier l'utilisateur (pour les tests suivants)
console.log('\n5.3 Réassociation de l\'utilisateur (pour les tests)...');
if (this.testData.testUserId && this.testData.merchantConfigId) {
const reassociationData = {
userId: this.testData.testUserId,
merchantConfigId: this.testData.merchantConfigId,
role: UserRole.MERCHANT_CONFIG_ADMIN
};
await firstValueFrom(
this.merchantCrudService.associateUserToMerchant(reassociationData)
);
console.log('✅ Utilisateur réassocié pour les tests suivants');
}
} catch (error) {
console.error('❌ ERREUR opérations utilisateurs:', error);
}
}
/**
* TEST 6: Opérations de suppression
*/
private async testDeleteOperations(): Promise<void> {
console.log('\n🗑 TEST 6: Opérations DELETE');
console.log('-'.repeat(40));
if (!this.testData.merchantConfigId) {
console.log('⚠️ Aucun merchant créé, passage au test suivant');
return;
}
try {
// 6.1 Dissocier avant suppression
console.log('6.1 Dissociation de l\'utilisateur avant suppression...');
if (this.testData.testUserId && this.testData.merchantConfigId) {
await firstValueFrom(
this.merchantCrudService.dissociateUserFromMerchant(
this.testData.testUserId,
this.testData.merchantConfigId
)
);
console.log('✅ Utilisateur dissocié');
}
// 6.2 Supprimer l'utilisateur de Keycloak
console.log('\n6.2 Suppression de l\'utilisateur de Keycloak...');
if (this.testData.testUserId) {
const deleteUserResult = await firstValueFrom(
this.merchantCrudService.deleteKeycloakUser(
this.testData.testUserId
)
);
console.log('✅ Utilisateur supprimé de Keycloak:');
console.log(' Succès:', deleteUserResult.success);
console.log(' Message:', deleteUserResult.message);
this.testData.testUserId = '';
this.testData.testMerchantConfigUserId = '';
}
// 6.3 Supprimer le merchant de MerchantConfig
console.log('\n6.3 Suppression du merchant de MerchantConfig...');
const deleteMerchantResult = await firstValueFrom(
this.merchantCrudService.deleteMerchantFromConfigOnly(
this.testData.merchantConfigId
)
);
console.log('✅ Merchant supprimé de MerchantConfig:');
console.log(' Succès:', deleteMerchantResult.success);
console.log(' Message:', deleteMerchantResult.message);
// 6.4 Vérifier la suppression
console.log('\n6.4 Vérification de la suppression...');
try {
await firstValueFrom(
this.merchantCrudService.getMerchantFromConfigOnly(
this.testData.merchantConfigId
)
);
console.log('❌ Le merchant existe toujours dans MerchantConfig - PROBLÈME!');
} catch (error) {
console.log('✅ Le merchant a bien été supprimé de MerchantConfig');
}
// Réinitialiser toutes les données
this.testData = {
merchantConfigId: '',
keycloakUserId: '',
testUserId: '',
testMerchantConfigUserId: '',
associatedUserId: ''
};
console.log('🧹 Données de test réinitialisées');
} catch (error) {
console.error('❌ ERREUR lors de la suppression:', error);
}
}
// ==================== MÉTHODES POUR TESTS INDIVIDUELS ====================
async testCreateOnly(): Promise<void> {
console.log('🧪 Test CREATE uniquement');
console.log('📌 Création indépendante');
await this.testCreateOperations();
}
async testAssociationOnly(): Promise<void> {
console.log('🧪 Test ASSOCIATION uniquement');
// Créer d'abord un merchant et un utilisateur si nécessaire
if (!this.testData.merchantConfigId) {
await this.createTestMerchant();
}
if (!this.testData.testUserId) {
await this.createTestUser();
}
await this.testAssociationOperations();
}
async testReadOnly(): Promise<void> {
console.log('🧪 Test READ uniquement');
await this.testReadOperations();
}
async testUpdateOnly(): Promise<void> {
console.log('🧪 Test UPDATE uniquement');
await this.testUpdateOperations();
}
async testDeleteOnly(): Promise<void> {
console.log('🧪 Test DELETE uniquement');
await this.testDeleteOperations();
}
// ==================== MÉTHODES UTILITAIRES ====================
private async createTestMerchant(): Promise<void> {
const merchantData = {
name: 'Test Merchant ' + Date.now(),
adresse: '123 Test Street',
phone: '+336' + Math.floor(10000000 + Math.random() * 90000000),
configs: [],
technicalContacts: []
};
const merchant = await firstValueFrom(
this.merchantCrudService.createMerchantInConfigOnly(merchantData)
);
this.testData.merchantConfigId = String(merchant.id!);
console.log('✅ Merchant de test créé:', this.testData.merchantConfigId);
}
private async createTestUser(): Promise<void> {
const userData = {
username: `testuser.${Date.now()}`,
email: `user.${Date.now()}@example.com`,
password: 'TestPassword123!',
firstName: 'Test',
lastName: 'User',
role: UserRole.DCB_PARTNER_ADMIN
};
const user = await firstValueFrom(
this.merchantCrudService.createKeycloakUser(userData)
);
this.testData.testUserId = user.id;
console.log('✅ Utilisateur de test créé:', this.testData.testUserId);
}
/**
* Afficher l'état actuel des tests
*/
showTestStatus(): void {
console.log('\n📊 ÉTAT ACTUEL DES TESTS');
console.log('📌 NOUVELLE LOGIQUE: Association séparée');
console.log('-'.repeat(30));
console.log('Merchant Config ID:', this.testData.merchantConfigId || 'Non créé');
console.log('Keycloak User ID:', this.testData.testUserId || 'Non créé');
console.log('Associated User ID:', this.testData.associatedUserId || 'Non associé');
console.log('Merchant Config User ID:', this.testData.testMerchantConfigUserId || 'Non créé');
}
/**
* Réinitialiser les données de test
*/
resetTestData(): void {
this.testData = {
merchantConfigId: '',
keycloakUserId: '',
testUserId: '',
testMerchantConfigUserId: '',
associatedUserId: ''
};
console.log('🧹 Données de test réinitialisées');
}
}

View File

@ -1 +0,0 @@
<p>Team</p>

View File

@ -1,2 +0,0 @@
import { Team } from './team';
describe('Team', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-team',
templateUrl: './team.html',
})
export class Team {}

View File

@ -1,15 +0,0 @@
export type ContactType = {
name: string
avatar: string
country: {
name: string
flag: string
}
jobTitle: string
about: string
verified?: boolean
rating: number
campaigns: number
contacts: number
engagement: string
}

View File

@ -1 +0,0 @@
<p>Webhooks - History</p>

View File

@ -1,2 +0,0 @@
import { WebhooksHistory } from './history';
describe('WebhooksHistory', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-webhooks-history',
templateUrl: './history.html',
})
export class WebhooksHistory {}

View File

@ -1 +0,0 @@
<p>Webhooks - Retry</p>

View File

@ -1,2 +0,0 @@
import { WebhooksRetry } from './retry';
describe('WebhooksRetry', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-webhooks-retry',
templateUrl: './retry.html',
})
export class WebhooksRetry {}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class WebhooksHistoryService {
constructor() {}
}

View File

@ -1,32 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class WebhookRetryService {
private http = inject(HttpClient);
private apiUrl = `${environment.localServiceTestApiUrl}/webhooks`;
retryWebhook(webhookId: string): Observable<{ success: boolean }> {
return this.http.post<{ success: boolean }>(
`${this.apiUrl}/${webhookId}/retry`,
{}
);
}
bulkRetryWebhooks(webhookIds: string[]): Observable<{ success: number; failed: number }> {
return this.http.post<{ success: number; failed: number }>(
`${this.apiUrl}/bulk-retry`,
{ webhookIds }
);
}
getRetryConfig(): Observable<any> {
return this.http.get(`${this.apiUrl}/retry-config`);
}
updateRetryConfig(config: any): Observable<any> {
return this.http.put(`${this.apiUrl}/retry-config`, config);
}
}

View File

@ -1,8 +0,0 @@
import { Injectable } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class WebhooksStatusService {
constructor() {}
}

View File

@ -1,45 +0,0 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { environment } from '@environments/environment';
import { Observable } from 'rxjs';
export interface WebhookEvent {
id: string;
url: string;
eventType: string;
payload: any;
status: 'SUCCESS' | 'FAILED' | 'PENDING';
retryCount: number;
createdAt: Date;
lastAttempt?: Date;
errorMessage?: string;
}
export interface WebhookFilter {
status?: string;
eventType?: string;
startDate?: Date;
endDate?: Date;
}
@Injectable({ providedIn: 'root' })
export class WebhookService {
private http = inject(HttpClient);
private apiUrl = `${environment.localServiceTestApiUrl}/webhooks`;
getWebhookHistory(filters?: WebhookFilter): Observable<WebhookEvent[]> {
return this.http.post<WebhookEvent[]>(
`${this.apiUrl}/history`,
filters
);
}
getWebhookStatus(): Observable<{
total: number;
success: number;
failed: number;
pending: number;
}> {
return this.http.get<any>(`${this.apiUrl}/status`);
}
}

View File

@ -1 +0,0 @@
<p>Webhooks - Status</p>

View File

@ -1,2 +0,0 @@
import { WebhooksStatus } from './status';
describe('WebhooksStatus', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-webhooks-status',
templateUrl: './status.html',
})
export class WebhooksStatus {}

View File

@ -1 +0,0 @@
<p>Webhooks</p>

View File

@ -1,2 +0,0 @@
import { Webhooks } from './webhooks';
describe('Webhooks', () => {});

View File

@ -1,7 +0,0 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-webhooks',
templateUrl: './webhooks.html',
})
export class Webhooks {}

1
src/assets/images/01.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 154 36" xmlns:v="https://vecta.io/nano"><path d="M20.9 30.8h25.6V5.2H20.9v25.6zm20.8-15.3h-5.4v-5.4h5.4v5.4zm-15.8-5.4h5.4v10.4h10.4v5.4H25.9V10.1zm37.4-4.9c-2.5 0-5 .8-7.1 2.2s-3.8 3.4-4.7 5.8c-1 2.3-1.2 4.9-.7 7.4s1.7 4.8 3.5 6.6 4.1 3 6.6 3.5 5.1.2 7.4-.7a13.93 13.93 0 0 0 5.8-4.7c1.4-2.1 2.2-4.6 2.2-7.1 0-3.4-1.4-6.7-3.8-9.1-2.5-2.6-5.8-3.9-9.2-3.9zm0 20.7c-1.6 0-3.1-.5-4.4-1.3-1.3-.9-2.3-2.1-2.9-3.5s-.8-3-.4-4.6c.3-1.5 1.1-2.9 2.2-4s2.5-1.9 4-2.2 3.1-.1 4.6.4c1.4.6 2.7 1.6 3.5 2.9.9 1.3 1.3 2.8 1.3 4.4 0 2.1-.8 4.1-2.3 5.6s-3.5 2.3-5.6 2.3zM122 5.2c-2.5 0-5 .8-7.1 2.2s-3.8 3.4-4.7 5.8c-1 2.3-1.2 4.9-.7 7.4s1.7 4.8 3.5 6.6 4.1 3 6.6 3.5 5.1.2 7.4-.7a13.93 13.93 0 0 0 5.8-4.7c1.4-2.1 2.2-4.6 2.2-7.1 0-3.4-1.4-6.7-3.8-9.1-2.5-2.6-5.8-3.9-9.2-3.9zm0 20.7c-1.6 0-3.1-.5-4.4-1.3-1.3-.9-2.3-2.1-2.9-3.5s-.8-3-.4-4.6c.3-1.5 1.1-2.9 2.2-4s2.5-1.9 4-2.2 3.1-.1 4.6.4c1.4.6 2.7 1.6 3.5 2.9.9 1.3 1.3 2.8 1.3 4.4 0 2.1-.8 4.1-2.3 5.6s-3.5 2.3-5.6 2.3zM92.7 5.2c-2.5 0-5 .8-7.1 2.2s-3.8 3.4-4.7 5.8c-1 2.3-1.2 4.9-.7 7.4s1.7 4.8 3.5 6.6 4.1 3 6.6 3.5 5.1.2 7.4-.7a13.93 13.93 0 0 0 5.8-4.7c1.4-2.1 2.2-4.6 2.2-7.1 0-3.4-1.4-6.7-3.8-9.1-2.6-2.6-5.8-3.9-9.2-3.9zm0 20.7c-1.5 0-2.9-.4-4.1-1.2s-2.2-1.8-2.9-3.1-1-2.7-.8-4.2c.1-1.4.6-2.8 1.5-4s2-2.1 3.4-2.7c1.3-.6 2.8-.7 4.2-.5s2.8.8 3.9 1.8c1.1.9 1.9 2.2 2.4 3.5h-7.5v4.9h7.5c-.5 1.6-1.5 2.9-2.9 3.9-1.5 1.1-3.1 1.6-4.7 1.6z" fill="#b4b7c9"/></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -4,5 +4,5 @@ export const environment = {
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/',
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/'
};

View File

@ -2,7 +2,7 @@ export const environment = {
production: true,
localServiceTestApiUrl: "https://backoffice.dcb.pixpay.sn/api/v1",
iamApiUrl: "https://api-user-service.dcb.pixpay.sn/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/',
configApiUrl: "https://api-merchant-config-service.dcb.pixpay.sn/api/v1",
apiCoreUrl: "https://api-core-service.dcb.pixpay.sn/api/v1",
reportingApiUrl: "https://api-reporting-service.dcb.pixpay.sn/api/v1/"
};

View File

@ -1,8 +1,8 @@
export const environment = {
production: false,
localServiceTestApiUrl: "http://localhost:4200/api/v1",
iamApiUrl: "http://localhost:3000/api/v1",
configApiUrl: 'https://api-merchant-config-service.dcb.pixpay.sn/api/v1',
apiCoreUrl: 'https://api-core-service.dcb.pixpay.sn/api/v1',
reportingApiUrl: 'https://api-reporting-service.dcb.pixpay.sn/api/v1/',
iamApiUrl: "http://localhost:3001/api/v1",
configApiUrl: "http://localhost:3000/api/v1",
apiCoreUrl: "https://api-core-service.dcb.pixpay.sn/api/v1",
reportingApiUrl: "https://api-reporting-service.dcb.pixpay.sn/api/v1/"
}