Moving away from Azure Devops #1
18 changed files with 4496 additions and 25 deletions
|
|
@ -18,7 +18,10 @@
|
||||||
"Bash(powershell -Command \"Get-ChildItem -Path src -Filter ''index.ts'' -Recurse | Select-Object -ExpandProperty FullName\")",
|
"Bash(powershell -Command \"Get-ChildItem -Path src -Filter ''index.ts'' -Recurse | Select-Object -ExpandProperty FullName\")",
|
||||||
"Bash(powershell -Command:*)",
|
"Bash(powershell -Command:*)",
|
||||||
"WebFetch(domain:www.npmjs.com)",
|
"WebFetch(domain:www.npmjs.com)",
|
||||||
"WebFetch(domain:unpkg.com)"
|
"WebFetch(domain:unpkg.com)",
|
||||||
|
"Bash(node -e:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(find:*)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
.workbench/StandardMapping.xlsx
Normal file
BIN
.workbench/StandardMapping.xlsx
Normal file
Binary file not shown.
244
package-lock.json
generated
244
package-lock.json
generated
|
|
@ -33,10 +33,12 @@
|
||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
"postcss-nesting": "^13.0.2",
|
"postcss-nesting": "^13.0.2",
|
||||||
"purgecss": "^7.0.2",
|
"purgecss": "^7.0.2",
|
||||||
|
"read-excel-file": "^6.0.1",
|
||||||
"rollup": "^4.52.5",
|
"rollup": "^4.52.5",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@asamuzakjp/css-color": {
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
|
@ -1348,6 +1350,16 @@
|
||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xmldom/xmldom": {
|
||||||
|
"version": "0.8.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||||
|
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
|
|
@ -1360,6 +1372,16 @@
|
||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
|
|
@ -1510,6 +1532,13 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bluebird": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/boolbase": {
|
"node_modules/boolbase": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||||
|
|
@ -1618,6 +1647,20 @@
|
||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chai": {
|
"node_modules/chai": {
|
||||||
"version": "5.3.3",
|
"version": "5.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
|
||||||
|
|
@ -1781,6 +1824,16 @@
|
||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
|
@ -1834,6 +1887,26 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/core-util-is": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
|
|
@ -2310,6 +2383,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/duplexer2": {
|
||||||
|
"version": "0.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz",
|
||||||
|
"integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/eastasianwidth": {
|
"node_modules/eastasianwidth": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
|
||||||
|
|
@ -2581,6 +2664,16 @@
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||||
|
|
@ -2858,6 +2951,13 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inherits": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/is-binary-path": {
|
"node_modules/is-binary-path": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
|
@ -3036,6 +3136,13 @@
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/isarray": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
|
|
@ -3325,6 +3432,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-int64": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.27",
|
"version": "2.0.27",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||||
|
|
@ -4289,6 +4403,13 @@
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/process-nextick-args": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pseudo-classes": {
|
"node_modules/pseudo-classes": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/pseudo-classes/-/pseudo-classes-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/pseudo-classes/-/pseudo-classes-1.0.0.tgz",
|
||||||
|
|
@ -4339,6 +4460,34 @@
|
||||||
"pify": "^2.3.0"
|
"pify": "^2.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/read-excel-file": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/read-excel-file/-/read-excel-file-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-rH6huBFxsjZsUARCYh55O08cn1gqZH8bnLf0kI6y5K7+9yqBVzy8veO4gPV4VGKv4M9rdcRtXTDGZZNwPi1gDA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xmldom/xmldom": "^0.8.11",
|
||||||
|
"fflate": "^0.8.2",
|
||||||
|
"unzipper": "^0.12.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/readable-stream": {
|
||||||
|
"version": "2.3.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||||
|
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"core-util-is": "~1.0.0",
|
||||||
|
"inherits": "~2.0.3",
|
||||||
|
"isarray": "~1.0.0",
|
||||||
|
"process-nextick-args": "~2.0.0",
|
||||||
|
"safe-buffer": "~5.1.1",
|
||||||
|
"string_decoder": "~1.1.1",
|
||||||
|
"util-deprecate": "~1.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
|
@ -4479,6 +4628,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-buffer": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||||
|
|
@ -4597,6 +4753,19 @@
|
||||||
"specificity": "bin/specificity"
|
"specificity": "bin/specificity"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stackback": {
|
"node_modules/stackback": {
|
||||||
"version": "0.0.2",
|
"version": "0.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||||
|
|
@ -4611,6 +4780,16 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/string_decoder": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safe-buffer": "~5.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "5.1.2",
|
"version": "5.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
|
||||||
|
|
@ -5048,6 +5227,27 @@
|
||||||
"node": ">=18.12.0"
|
"node": ">=18.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/unzipper": {
|
||||||
|
"version": "0.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.12.3.tgz",
|
||||||
|
"integrity": "sha512-PZ8hTS+AqcGxsaQntl3IRBw65QrBI6lxzqDEL7IAo/XCEqRTKGfOX56Vea5TH9SZczRVxuzk1re04z/YjuYCJA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bluebird": "~3.7.2",
|
||||||
|
"duplexer2": "~0.1.4",
|
||||||
|
"fs-extra": "^11.2.0",
|
||||||
|
"graceful-fs": "^4.2.2",
|
||||||
|
"node-int64": "^0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/unzipper/node_modules/graceful-fs": {
|
||||||
|
"version": "4.2.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||||
|
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz",
|
||||||
|
|
@ -5796,6 +5996,26 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wrap-ansi": {
|
"node_modules/wrap-ansi": {
|
||||||
"version": "8.1.0",
|
"version": "8.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
|
||||||
|
|
@ -5916,6 +6136,28 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xml-name-validator": {
|
"node_modules/xml-name-validator": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,12 @@
|
||||||
"postcss-cli": "^11.0.1",
|
"postcss-cli": "^11.0.1",
|
||||||
"postcss-nesting": "^13.0.2",
|
"postcss-nesting": "^13.0.2",
|
||||||
"purgecss": "^7.0.2",
|
"purgecss": "^7.0.2",
|
||||||
|
"read-excel-file": "^6.0.1",
|
||||||
"rollup": "^4.52.5",
|
"rollup": "^4.52.5",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4",
|
||||||
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@novadi/core": "^0.6.0",
|
"@novadi/core": "^0.6.0",
|
||||||
|
|
|
||||||
255
wwwroot/docs/ai-booking-optimering.md
Normal file
255
wwwroot/docs/ai-booking-optimering.md
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
# AI Booking Optimering
|
||||||
|
|
||||||
|
## Produktoversigt
|
||||||
|
|
||||||
|
AI Booking Optimering er et intelligent tillægsmodul der hjælper saloner med at maksimere deres kalenderudnyttelse og reducere tabt omsætning fra tomme tidsslots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### Feature 1: Smart Tidsforslag (Real-time)
|
||||||
|
|
||||||
|
Når kunder booker online, analyserer AI'en eksisterende bookinger og foreslår de mest optimale tidspunkter.
|
||||||
|
|
||||||
|
**Hvordan det virker:**
|
||||||
|
1. Kunden vælger ydelse (f.eks. Dameklip, 60 min)
|
||||||
|
2. AI'en analyserer dagens/ugens bookinger for den valgte medarbejder
|
||||||
|
3. Hvert ledigt tidsslot får en score baseret på:
|
||||||
|
- Minimering af huller
|
||||||
|
- Optimal udnyttelse af åbningstiden
|
||||||
|
- Kontinuitet i bookinger
|
||||||
|
4. Top 2-3 bedste slots markeres med "Anbefalet" badge
|
||||||
|
|
||||||
|
**Scoring-algoritme:**
|
||||||
|
|
||||||
|
| Kriterium | Score |
|
||||||
|
|-----------|-------|
|
||||||
|
| Starter ved åbningstid | +3 |
|
||||||
|
| Slutter præcis på næste booking | +3 |
|
||||||
|
| Starter lige efter en booking | +2 |
|
||||||
|
| Slutter ved lukketid | +1 |
|
||||||
|
| Skaber hul < 30 min | -2 |
|
||||||
|
|
||||||
|
**UX-principper:**
|
||||||
|
- Blød anbefaling - kunden kan stadig vælge alle ledige tider
|
||||||
|
- Grøn badge med AI-ikon på anbefalede tider
|
||||||
|
- Info-tekst forklarer fordelen
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Feature 2: Kalender-optimering Dashboard
|
||||||
|
|
||||||
|
Salonejere får et dashboard der identificerer huller og foreslår handlinger.
|
||||||
|
|
||||||
|
**Dashboard-komponenter:**
|
||||||
|
|
||||||
|
1. **Statistik-kort**
|
||||||
|
- Huller i dag
|
||||||
|
- Tabt omsætning (estimeret)
|
||||||
|
- Huller denne uge
|
||||||
|
- Potentiel besparelse
|
||||||
|
|
||||||
|
2. **Mini-kalender**
|
||||||
|
- Visuel oversigt over ugen
|
||||||
|
- Farvekodede dage (grøn = optimalt, gul = huller, rød = kritisk)
|
||||||
|
|
||||||
|
3. **Hul-liste**
|
||||||
|
- Detaljeret visning af hvert identificeret hul
|
||||||
|
- Medarbejder og tidspunkt
|
||||||
|
- Estimeret tabt omsætning
|
||||||
|
- AI-forslag til at fylde hullet
|
||||||
|
|
||||||
|
4. **AI-forslag typer:**
|
||||||
|
- **Flyt booking**: Foreslå at flytte en eksisterende kundes tid
|
||||||
|
- **Venteliste**: Kontakt kunde fra ventelisten
|
||||||
|
- **Rabattilbud**: Send SMS med rabat for at fylde hullet
|
||||||
|
|
||||||
|
5. **SMS-historik**
|
||||||
|
- Track sendte tilbud
|
||||||
|
- Accept/afvisning statistik
|
||||||
|
- Pending tilbud
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Teknisk Implementation
|
||||||
|
|
||||||
|
### POC 1: poc-booking-v2.html
|
||||||
|
|
||||||
|
**Tilføjede komponenter:**
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Mock data for eksisterende bookinger
|
||||||
|
const existingBookings = {
|
||||||
|
'EMP001': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '10:00', end: '11:00', service: 'Dameklip' },
|
||||||
|
{ start: '13:30', end: '14:30', service: 'Herreklip' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scoring-algoritme
|
||||||
|
function calculateOptimalSlots(serviceDuration, date, employeeId) {
|
||||||
|
// 1. Hent bookinger for dato/medarbejder
|
||||||
|
// 2. Generer alle mulige slots (30 min intervaller)
|
||||||
|
// 3. Tjek overlap med eksisterende bookinger
|
||||||
|
// 4. Beregn score for hvert ledigt slot
|
||||||
|
// 5. Marker top 3 med positiv score som "recommended"
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**CSS-styling:**
|
||||||
|
- `.time-slot.recommended` - Grøn border og baggrund
|
||||||
|
- `.ai-badge` - Absolut positioneret badge med sparkle-ikon
|
||||||
|
- `.ai-info` - Info-boks over tidsgrid
|
||||||
|
|
||||||
|
### POC 2: poc-ai-booking-optimizer.html
|
||||||
|
|
||||||
|
**Struktur:**
|
||||||
|
- Topbar med AI-badge
|
||||||
|
- Stats-grid med 4 KPI-kort
|
||||||
|
- Main-grid med kalender og hul-liste
|
||||||
|
- Sidebar med optimeringsscore og SMS-historik
|
||||||
|
|
||||||
|
**Mock data:**
|
||||||
|
- `gaps[]` - Identificerede huller med forslag
|
||||||
|
- `weekDays[]` - Ugevisning med gap-status
|
||||||
|
- `smsHistory[]` - Historik over sendte tilbud
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Forretningsværdi
|
||||||
|
|
||||||
|
### ROI-beregning
|
||||||
|
|
||||||
|
```
|
||||||
|
Typisk salon:
|
||||||
|
- 4 medarbejdere
|
||||||
|
- 40 timer/uge pr. medarbejder
|
||||||
|
- 15% tomme slots = 24 timer/uge tabt
|
||||||
|
- Gennemsnitlig timepris: 500 kr.
|
||||||
|
- Tabt omsætning: 12.000 kr./uge = 624.000 kr./år
|
||||||
|
|
||||||
|
AI-optimering fylder 50% af huller:
|
||||||
|
- Ekstra omsætning: 312.000 kr./år
|
||||||
|
|
||||||
|
Pris for AI-modul: 499 kr./md = 5.988 kr./år
|
||||||
|
ROI: 52x investering
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nøgletal at tracke
|
||||||
|
|
||||||
|
| Metrik | Beskrivelse |
|
||||||
|
|--------|-------------|
|
||||||
|
| Kalenderudnyttelse | % af tilgængelige timer der er booket |
|
||||||
|
| Gennemsnitligt hul | Minutter tabt pr. dag i gaps |
|
||||||
|
| Accept-rate | % af kunder der accepterer flyttetilbud |
|
||||||
|
| Tabt omsætning | Estimeret kr. i tomme slots |
|
||||||
|
| Optimeringsscore | Samlet effektivitet (mål: 90%+) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fremtidig AI-udvidelse
|
||||||
|
|
||||||
|
### Niveau 1: Regelbaseret (Nuværende POC)
|
||||||
|
- Statiske scoring-regler
|
||||||
|
- Ingen læring
|
||||||
|
- Fungerer for alle saloner ens
|
||||||
|
|
||||||
|
### Niveau 2: Machine Learning
|
||||||
|
- Lærer fra salonens historik
|
||||||
|
- Personlige kundeprofilenr
|
||||||
|
- Forudsigelse af no-shows
|
||||||
|
- Dynamisk prisjustering
|
||||||
|
|
||||||
|
### ML-features (fremtidig):
|
||||||
|
|
||||||
|
1. **Historisk mønstergenkendelse**
|
||||||
|
- Populære vs. døde tider
|
||||||
|
- Sæsonvariation
|
||||||
|
- Service-specifikke mønstre
|
||||||
|
|
||||||
|
2. **Kundesegmentering**
|
||||||
|
- Fleksible vs. fastlåste kunder
|
||||||
|
- Pris-sensitive kunder
|
||||||
|
- No-show risiko-profiler
|
||||||
|
|
||||||
|
3. **Intelligent rabat-beregning**
|
||||||
|
- Dynamisk rabat baseret på:
|
||||||
|
- Hullets "værdi"
|
||||||
|
- Kundens prissensitivitet
|
||||||
|
- Sandsynlighed for naturlig booking
|
||||||
|
|
||||||
|
4. **Proaktiv optimering**
|
||||||
|
- Forudsig huller før de opstår
|
||||||
|
- Automatisk udsend tilbud
|
||||||
|
- Venteliste-matching
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration med eksisterende system
|
||||||
|
|
||||||
|
### Data-flow
|
||||||
|
|
||||||
|
```
|
||||||
|
Booking system
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐
|
||||||
|
│ AI Optimizer │
|
||||||
|
│ - Analyse │
|
||||||
|
│ - Scoring │
|
||||||
|
│ - Anbefalinger │
|
||||||
|
└─────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────┐ ┌─────────────────┐
|
||||||
|
│ Booking Widget │ │ Dashboard │
|
||||||
|
│ (kundevisning) │ │ (ejervisning) │
|
||||||
|
└─────────────────┘ └─────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### API-endpoints (fremtidig)
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/ai/optimal-slots?date=X&employee=Y&duration=Z
|
||||||
|
POST /api/ai/send-offer
|
||||||
|
GET /api/ai/gaps?week=X
|
||||||
|
GET /api/ai/stats
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Konfiguration
|
||||||
|
|
||||||
|
### Indstillinger pr. salon
|
||||||
|
|
||||||
|
| Indstilling | Beskrivelse | Default |
|
||||||
|
|-------------|-------------|---------|
|
||||||
|
| `minGapMinutes` | Mindste hul der tæller som tabt | 30 min |
|
||||||
|
| `recommendedSlots` | Antal anbefalede slots | 3 |
|
||||||
|
| `defaultDiscount` | Standard rabat ved flytning | 5% |
|
||||||
|
| `autoSendOffers` | Automatisk udsend tilbud | Fra |
|
||||||
|
| `smsEnabled` | Aktiver SMS-udsendelse | Til |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Filer
|
||||||
|
|
||||||
|
| Fil | Beskrivelse |
|
||||||
|
|-----|-------------|
|
||||||
|
| `poc-booking-v2.html` | Kundens booking-widget med AI-anbefalinger |
|
||||||
|
| `poc-ai-booking-optimizer.html` | Dashboard til salonejere |
|
||||||
|
| `docs/ai-booking-optimering.md` | Denne dokumentation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Changelog
|
||||||
|
|
||||||
|
### Version 1.0 (Januar 2026)
|
||||||
|
- Initial POC implementation
|
||||||
|
- Regelbaseret scoring-algoritme
|
||||||
|
- Dashboard med hul-identifikation
|
||||||
|
- SMS-historik tracking
|
||||||
1009
wwwroot/poc-ai-booking-optimizer.html
Normal file
1009
wwwroot/poc-ai-booking-optimizer.html
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -504,6 +504,58 @@
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* AI Recommended Slots */
|
||||||
|
.time-slot.recommended {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid var(--color-green);
|
||||||
|
background: color-mix(in srgb, var(--color-green) 8%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot.recommended:hover:not(.disabled):not(.selected) {
|
||||||
|
background: color-mix(in srgb, var(--color-green) 15%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-slot.recommended.selected {
|
||||||
|
background: var(--color-green);
|
||||||
|
border-color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -8px;
|
||||||
|
right: -8px;
|
||||||
|
background: var(--color-green);
|
||||||
|
color: white;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
font-weight: 500;
|
||||||
|
font-family: var(--font-family);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-badge i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--color-green) 10%, white);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ai-info i {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ==========================================
|
/* ==========================================
|
||||||
WAITLIST
|
WAITLIST
|
||||||
========================================== */
|
========================================== */
|
||||||
|
|
@ -1917,6 +1969,133 @@
|
||||||
{ id: "EMP004", name: "Viktor", role: "Junior Stylist", color: "#009688", priceModifier: -100 }
|
{ id: "EMP004", name: "Viktor", role: "Junior Stylist", color: "#009688", priceModifier: -100 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// EKSISTERENDE BOOKINGER (Mock data til AI-optimering)
|
||||||
|
// ==========================================
|
||||||
|
const existingBookings = {
|
||||||
|
'EMP001': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '10:00', end: '11:00', service: 'Dameklip' },
|
||||||
|
{ start: '13:30', end: '14:30', service: 'Herreklip' }
|
||||||
|
],
|
||||||
|
'2026-01-07': [
|
||||||
|
{ start: '09:00', end: '10:30', service: 'Bundfarve' },
|
||||||
|
{ start: '11:00', end: '12:00', service: 'Dameklip' },
|
||||||
|
{ start: '14:00', end: '15:00', service: 'Dameklip' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'EMP002': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '09:00', end: '10:00', service: 'Herreklip' },
|
||||||
|
{ start: '11:00', end: '12:00', service: 'Dameklip' },
|
||||||
|
{ start: '15:00', end: '16:30', service: 'Striber' }
|
||||||
|
],
|
||||||
|
'2026-01-07': [
|
||||||
|
{ start: '10:00', end: '11:00', service: 'Dameklip' },
|
||||||
|
{ start: '13:00', end: '14:00', service: 'Herreklip' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'EMP003': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '08:30', end: '09:30', service: 'Herreklip' },
|
||||||
|
{ start: '12:00', end: '13:00', service: 'Dameklip' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'EMP004': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '09:00', end: '10:00', service: 'Herreklip' },
|
||||||
|
{ start: '14:00', end: '15:30', service: 'Bundfarve' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SALON_OPEN = '08:00';
|
||||||
|
const SALON_CLOSE = '17:00';
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// AI SLOT OPTIMERING
|
||||||
|
// ==========================================
|
||||||
|
function timeToMinutes(time) {
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesToTime(mins) {
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBookingsForSlot(date, employeeId) {
|
||||||
|
if (!employeeId) {
|
||||||
|
let bestEmployee = 'EMP001';
|
||||||
|
let minBookings = Infinity;
|
||||||
|
for (const empId of Object.keys(existingBookings)) {
|
||||||
|
const bookings = existingBookings[empId]?.[date] || [];
|
||||||
|
if (bookings.length < minBookings) {
|
||||||
|
minBookings = bookings.length;
|
||||||
|
bestEmployee = empId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existingBookings[bestEmployee]?.[date] || [];
|
||||||
|
}
|
||||||
|
return existingBookings[employeeId]?.[date] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOptimalSlots(serviceDuration, date, employeeId) {
|
||||||
|
const bookings = getBookingsForSlot(date, employeeId);
|
||||||
|
const openMins = timeToMinutes(SALON_OPEN);
|
||||||
|
const closeMins = timeToMinutes(SALON_CLOSE);
|
||||||
|
|
||||||
|
const slots = [];
|
||||||
|
for (let mins = openMins; mins <= closeMins - serviceDuration; mins += 30) {
|
||||||
|
const slotStart = mins;
|
||||||
|
const slotEnd = mins + serviceDuration;
|
||||||
|
const time = minutesToTime(mins);
|
||||||
|
|
||||||
|
let isTaken = false;
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const bookStart = timeToMinutes(booking.start);
|
||||||
|
const bookEnd = timeToMinutes(booking.end);
|
||||||
|
if (slotStart < bookEnd && slotEnd > bookStart) {
|
||||||
|
isTaken = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let score = 0;
|
||||||
|
if (!isTaken) {
|
||||||
|
if (slotStart === openMins) score += 3;
|
||||||
|
for (const booking of bookings) {
|
||||||
|
if (slotEnd === timeToMinutes(booking.start)) { score += 3; break; }
|
||||||
|
}
|
||||||
|
for (const booking of bookings) {
|
||||||
|
if (slotStart === timeToMinutes(booking.end)) { score += 2; break; }
|
||||||
|
}
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const gap = timeToMinutes(booking.start) - slotEnd;
|
||||||
|
if (gap > 0 && gap < 30) { score -= 2; break; }
|
||||||
|
}
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const gap = slotStart - timeToMinutes(booking.end);
|
||||||
|
if (gap > 0 && gap < 30) { score -= 2; break; }
|
||||||
|
}
|
||||||
|
if (slotEnd >= closeMins - 60) score += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
slots.push({ time, taken: isTaken, score, recommended: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableSlots = slots.filter(s => !s.taken);
|
||||||
|
availableSlots.sort((a, b) => b.score - a.score);
|
||||||
|
const topSlots = availableSlots.slice(0, 3);
|
||||||
|
for (const slot of topSlots) {
|
||||||
|
if (slot.score > 0) slot.recommended = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// STEP ANIMATION
|
// STEP ANIMATION
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -2196,8 +2375,18 @@
|
||||||
daysHtml += `<div class="${cls}" data-date="${dateStr}">${d}</div>`;
|
daysHtml += `<div class="${cls}" data-date="${dateStr}">${d}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const times = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '13:00', '13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30'];
|
// Beregn total varighed for valgte ydelser
|
||||||
const taken = ['10:30', '14:00'];
|
const serviceDuration = state.services.reduce((sum, s) => sum + s.duration, 0) || 60;
|
||||||
|
|
||||||
|
// Brug AI-algoritme til at beregne optimale slots
|
||||||
|
const selectedDate = state.date || new Date().toISOString().split('T')[0];
|
||||||
|
const slots = calculateOptimalSlots(serviceDuration, selectedDate, state.employee);
|
||||||
|
|
||||||
|
// Filtrer til åbningstider (08:00-17:00)
|
||||||
|
const displaySlots = slots.filter(s => {
|
||||||
|
const mins = timeToMinutes(s.time);
|
||||||
|
return mins >= timeToMinutes('08:00') && mins <= timeToMinutes('16:30');
|
||||||
|
});
|
||||||
|
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="datetime-grid">
|
<div class="datetime-grid">
|
||||||
|
|
@ -2222,12 +2411,20 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="time-section">
|
<div class="time-section">
|
||||||
<div class="time-section-title">Ledige tider</div>
|
<div class="time-section-title">Ledige tider</div>
|
||||||
|
<div class="ai-info">
|
||||||
|
<i class="ph ph-sparkle"></i>
|
||||||
|
<span>Anbefalede tider passer bedst i vores kalender</span>
|
||||||
|
</div>
|
||||||
<div class="time-grid">
|
<div class="time-grid">
|
||||||
${times.map(t => {
|
${displaySlots.map(slot => {
|
||||||
let cls = 'time-slot';
|
let cls = 'time-slot';
|
||||||
if (taken.includes(t)) cls += ' disabled';
|
if (slot.taken) cls += ' disabled';
|
||||||
if (state.time === t) cls += ' selected';
|
if (slot.recommended && !slot.taken) cls += ' recommended';
|
||||||
return `<div class="${cls}" data-time="${t}">${t}</div>`;
|
if (state.time === slot.time) cls += ' selected';
|
||||||
|
return `<div class="${cls}" data-time="${slot.time}">
|
||||||
|
${slot.time}
|
||||||
|
${slot.recommended && !slot.taken ? '<span class="ai-badge"><i class="ph ph-sparkle"></i></span>' : ''}
|
||||||
|
</div>`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -568,6 +568,59 @@
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
swp-time-slot.recommended {
|
||||||
|
position: relative;
|
||||||
|
border: 2px solid var(--color-green);
|
||||||
|
background: color-mix(in srgb, var(--color-green) 10%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-time-slot.recommended:hover {
|
||||||
|
background: color-mix(in srgb, var(--color-green) 18%, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-time-slot.recommended.selected {
|
||||||
|
background: var(--color-teal);
|
||||||
|
border-color: var(--color-teal);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-ai-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
right: -10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
background: var(--color-green);
|
||||||
|
color: white;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-ai-badge i {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-ai-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: color-mix(in srgb, var(--color-green) 10%, white);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--color-green) 25%, transparent);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
swp-ai-info i {
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--color-green);
|
||||||
|
}
|
||||||
|
|
||||||
/* ==========================================
|
/* ==========================================
|
||||||
CONTACT FORM
|
CONTACT FORM
|
||||||
========================================== */
|
========================================== */
|
||||||
|
|
@ -1065,6 +1118,180 @@
|
||||||
{ id: "EMP004", name: "Viktor", role: "Frisør", color: "#009688" }
|
{ id: "EMP004", name: "Viktor", role: "Frisør", color: "#009688" }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// EKSISTERENDE BOOKINGER (Mock data til AI-optimering)
|
||||||
|
// ==========================================
|
||||||
|
const existingBookings = {
|
||||||
|
// Bookinger pr. medarbejder pr. dato
|
||||||
|
'EMP001': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '10:00', end: '11:00', service: 'Dameklip' },
|
||||||
|
{ start: '13:30', end: '14:30', service: 'Herreklip' }
|
||||||
|
],
|
||||||
|
'2026-01-07': [
|
||||||
|
{ start: '09:00', end: '10:30', service: 'Bundfarve' },
|
||||||
|
{ start: '11:00', end: '12:00', service: 'Dameklip' },
|
||||||
|
{ start: '14:00', end: '15:00', service: 'Dameklip' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'EMP002': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '09:00', end: '10:00', service: 'Herreklip' },
|
||||||
|
{ start: '11:00', end: '12:00', service: 'Dameklip' },
|
||||||
|
{ start: '15:00', end: '16:30', service: 'Striber' }
|
||||||
|
],
|
||||||
|
'2026-01-07': [
|
||||||
|
{ start: '10:00', end: '11:00', service: 'Dameklip' },
|
||||||
|
{ start: '13:00', end: '14:00', service: 'Herreklip' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'EMP003': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '08:30', end: '09:30', service: 'Herreklip' },
|
||||||
|
{ start: '12:00', end: '13:00', service: 'Dameklip' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'EMP004': {
|
||||||
|
'2026-01-06': [
|
||||||
|
{ start: '09:00', end: '10:00', service: 'Herreklip' },
|
||||||
|
{ start: '14:00', end: '15:30', service: 'Bundfarve' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const SALON_OPEN = '08:00';
|
||||||
|
const SALON_CLOSE = '17:00';
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// AI SLOT OPTIMERING
|
||||||
|
// ==========================================
|
||||||
|
function timeToMinutes(time) {
|
||||||
|
const [h, m] = time.split(':').map(Number);
|
||||||
|
return h * 60 + m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minutesToTime(mins) {
|
||||||
|
const h = Math.floor(mins / 60);
|
||||||
|
const m = mins % 60;
|
||||||
|
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBookingsForSlot(date, employeeId) {
|
||||||
|
// Hvis ingen præference, kombiner alle medarbejderes bookinger
|
||||||
|
if (!employeeId) {
|
||||||
|
// Find medarbejder med færrest bookinger (simulerer "første ledige")
|
||||||
|
let bestEmployee = 'EMP001';
|
||||||
|
let minBookings = Infinity;
|
||||||
|
for (const empId of Object.keys(existingBookings)) {
|
||||||
|
const bookings = existingBookings[empId]?.[date] || [];
|
||||||
|
if (bookings.length < minBookings) {
|
||||||
|
minBookings = bookings.length;
|
||||||
|
bestEmployee = empId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return existingBookings[bestEmployee]?.[date] || [];
|
||||||
|
}
|
||||||
|
return existingBookings[employeeId]?.[date] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateOptimalSlots(serviceDuration, date, employeeId) {
|
||||||
|
const bookings = getBookingsForSlot(date, employeeId);
|
||||||
|
const openMins = timeToMinutes(SALON_OPEN);
|
||||||
|
const closeMins = timeToMinutes(SALON_CLOSE);
|
||||||
|
|
||||||
|
// Generer alle mulige slots (30 min intervaller)
|
||||||
|
const slots = [];
|
||||||
|
for (let mins = openMins; mins <= closeMins - serviceDuration; mins += 30) {
|
||||||
|
const slotStart = mins;
|
||||||
|
const slotEnd = mins + serviceDuration;
|
||||||
|
const time = minutesToTime(mins);
|
||||||
|
|
||||||
|
// Tjek om slot overlapper med eksisterende booking
|
||||||
|
let isTaken = false;
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const bookStart = timeToMinutes(booking.start);
|
||||||
|
const bookEnd = timeToMinutes(booking.end);
|
||||||
|
if (slotStart < bookEnd && slotEnd > bookStart) {
|
||||||
|
isTaken = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Beregn score for dette slot
|
||||||
|
let score = 0;
|
||||||
|
|
||||||
|
if (!isTaken) {
|
||||||
|
// +3: Starter ved åbningstid
|
||||||
|
if (slotStart === openMins) {
|
||||||
|
score += 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
// +3: Slutter præcis på næste booking
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const bookStart = timeToMinutes(booking.start);
|
||||||
|
if (slotEnd === bookStart) {
|
||||||
|
score += 3;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// +2: Starter lige efter en booking
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const bookEnd = timeToMinutes(booking.end);
|
||||||
|
if (slotStart === bookEnd) {
|
||||||
|
score += 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -2: Skaber lille hul (< 30 min) til næste booking
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const bookStart = timeToMinutes(booking.start);
|
||||||
|
const gap = bookStart - slotEnd;
|
||||||
|
if (gap > 0 && gap < 30) {
|
||||||
|
score -= 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -2: Skaber lille hul fra forrige booking
|
||||||
|
for (const booking of bookings) {
|
||||||
|
const bookEnd = timeToMinutes(booking.end);
|
||||||
|
const gap = slotStart - bookEnd;
|
||||||
|
if (gap > 0 && gap < 30) {
|
||||||
|
score -= 2;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// +1: Sidst på dagen (fylder op bagfra)
|
||||||
|
if (slotEnd >= closeMins - 60) {
|
||||||
|
score += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slots.push({
|
||||||
|
time,
|
||||||
|
taken: isTaken,
|
||||||
|
score,
|
||||||
|
recommended: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marker top 3 ledige slots som recommended
|
||||||
|
const availableSlots = slots.filter(s => !s.taken);
|
||||||
|
availableSlots.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
const topSlots = availableSlots.slice(0, 3);
|
||||||
|
for (const slot of topSlots) {
|
||||||
|
if (slot.score > 0) {
|
||||||
|
slot.recommended = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return slots;
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// INIT
|
// INIT
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|
@ -1294,6 +1521,10 @@
|
||||||
</swp-calendar>
|
</swp-calendar>
|
||||||
<swp-time-section>
|
<swp-time-section>
|
||||||
<swp-section-title>Ledige tider</swp-section-title>
|
<swp-section-title>Ledige tider</swp-section-title>
|
||||||
|
<swp-ai-info>
|
||||||
|
<i class="ph ph-sparkle"></i>
|
||||||
|
<span>Anbefalede tider passer bedst i vores kalender og minimerer ventetid</span>
|
||||||
|
</swp-ai-info>
|
||||||
<swp-time-grid id="timeGrid"></swp-time-grid>
|
<swp-time-grid id="timeGrid"></swp-time-grid>
|
||||||
</swp-time-section>
|
</swp-time-section>
|
||||||
</swp-datetime-layout>
|
</swp-datetime-layout>
|
||||||
|
|
@ -1356,11 +1587,22 @@
|
||||||
const grid = document.getElementById('timeGrid');
|
const grid = document.getElementById('timeGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
const times = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '12:00', '13:00', '13:30', '14:00', '14:30', '15:00', '15:30', '16:00', '16:30'];
|
// Beregn samlet varighed af valgte services (eller default 60 min)
|
||||||
const taken = ['10:30', '14:00'];
|
const serviceDuration = state.services.reduce((sum, s) => sum + s.duration, 0) || 60;
|
||||||
|
|
||||||
grid.innerHTML = times.map(t => `
|
// Brug AI-algoritmen til at beregne optimale slots
|
||||||
<swp-time-slot data-time="${t}" class="${taken.includes(t) ? 'disabled' : ''} ${state.time === t ? 'selected' : ''}">${t}</swp-time-slot>
|
const selectedDate = state.date || new Date().toISOString().split('T')[0];
|
||||||
|
const slots = calculateOptimalSlots(serviceDuration, selectedDate, state.employee);
|
||||||
|
|
||||||
|
// Render slots med AI-anbefalinger
|
||||||
|
grid.innerHTML = slots.map(slot => `
|
||||||
|
<swp-time-slot
|
||||||
|
data-time="${slot.time}"
|
||||||
|
class="${slot.taken ? 'disabled' : ''} ${slot.recommended ? 'recommended' : ''} ${state.time === slot.time ? 'selected' : ''}"
|
||||||
|
>
|
||||||
|
${slot.time}
|
||||||
|
${slot.recommended ? '<swp-ai-badge><i class="ph ph-sparkle"></i>Anbefalet</swp-ai-badge>' : ''}
|
||||||
|
</swp-time-slot>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
grid.querySelectorAll('swp-time-slot:not(.disabled)').forEach(slot => {
|
grid.querySelectorAll('swp-time-slot:not(.disabled)').forEach(slot => {
|
||||||
|
|
|
||||||
|
|
@ -2609,21 +2609,17 @@
|
||||||
<swp-card>
|
<swp-card>
|
||||||
<swp-section-label>Provision</swp-section-label>
|
<swp-section-label>Provision</swp-section-label>
|
||||||
<swp-edit-section>
|
<swp-edit-section>
|
||||||
|
<swp-edit-row>
|
||||||
|
<swp-edit-label>Minimum pr. time</swp-edit-label>
|
||||||
|
<swp-edit-value contenteditable="true">220 kr</swp-edit-value>
|
||||||
|
</swp-edit-row>
|
||||||
<swp-edit-row>
|
<swp-edit-row>
|
||||||
<swp-edit-label>På services</swp-edit-label>
|
<swp-edit-label>På services</swp-edit-label>
|
||||||
<swp-edit-value contenteditable="true">12%</swp-edit-value>
|
<swp-edit-value contenteditable="true">15%</swp-edit-value>
|
||||||
</swp-edit-row>
|
</swp-edit-row>
|
||||||
<swp-edit-row>
|
<swp-edit-row>
|
||||||
<swp-edit-label>På produktsalg</swp-edit-label>
|
<swp-edit-label>På produktsalg</swp-edit-label>
|
||||||
<swp-edit-value contenteditable="true">8%</swp-edit-value>
|
<swp-edit-value contenteditable="true">15%</swp-edit-value>
|
||||||
</swp-edit-row>
|
|
||||||
<swp-edit-row>
|
|
||||||
<swp-edit-label>Bonus ved mål</swp-edit-label>
|
|
||||||
<swp-edit-value contenteditable="true">2.500 kr/md</swp-edit-value>
|
|
||||||
</swp-edit-row>
|
|
||||||
<swp-edit-row>
|
|
||||||
<swp-edit-label>Månedligt mål</swp-edit-label>
|
|
||||||
<swp-edit-value contenteditable="true">45.000 kr</swp-edit-value>
|
|
||||||
</swp-edit-row>
|
</swp-edit-row>
|
||||||
</swp-edit-section>
|
</swp-edit-section>
|
||||||
</swp-card>
|
</swp-card>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
1525
wwwroot/poc-loen-provision.html
Normal file
1525
wwwroot/poc-loen-provision.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue