DONE: queue management for customer side to get queue number, add logo to teller display, advertisement module
This commit is contained in:
+15
-12
@@ -1,14 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BBQMS Admin App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MyKOPKB QMS-Admin App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -14,6 +14,7 @@
|
||||
"bootstrap": "^5.3.3",
|
||||
"bootstrap-icons": "^1.11.3",
|
||||
"formik": "^2.4.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.2.0",
|
||||
"react-bootstrap": "^2.10.2",
|
||||
"react-dom": "^18.2.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"yup": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.2.64",
|
||||
"@types/react-dom": "^18.2.21",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
|
||||
Generated
+203
-5
@@ -20,6 +20,9 @@ importers:
|
||||
formik:
|
||||
specifier: ^2.4.5
|
||||
version: 2.4.9(@types/react@18.3.31)(react@18.3.1)
|
||||
qrcode:
|
||||
specifier: ^1.5.4
|
||||
version: 1.5.4
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.3.1
|
||||
@@ -45,6 +48,9 @@ importers:
|
||||
specifier: ^1.4.0
|
||||
version: 1.7.1
|
||||
devDependencies:
|
||||
'@types/qrcode':
|
||||
specifier: ^1.5.6
|
||||
version: 1.5.6
|
||||
'@types/react':
|
||||
specifier: ^18.2.64
|
||||
version: 18.3.31
|
||||
@@ -53,7 +59,7 @@ importers:
|
||||
version: 18.3.7(@types/react@18.3.31)
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^4.2.1
|
||||
version: 4.7.0(vite@5.4.21)
|
||||
version: 4.7.0(vite@5.4.21(@types/node@26.1.1))
|
||||
eslint:
|
||||
specifier: ^8.57.0
|
||||
version: 8.57.1
|
||||
@@ -68,7 +74,7 @@ importers:
|
||||
version: 0.4.26(eslint@8.57.1)
|
||||
vite:
|
||||
specifier: ^5.1.6
|
||||
version: 5.4.21
|
||||
version: 5.4.21(@types/node@26.1.1)
|
||||
|
||||
packages:
|
||||
|
||||
@@ -572,9 +578,15 @@ packages:
|
||||
'@types/lodash@4.17.24':
|
||||
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||
|
||||
'@types/prop-types@15.7.15':
|
||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||
|
||||
'@types/react-dom@18.3.7':
|
||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||
peerDependencies:
|
||||
@@ -707,6 +719,10 @@ packages:
|
||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
camelcase@5.3.1:
|
||||
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
caniuse-lite@1.0.30001806:
|
||||
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
||||
|
||||
@@ -717,6 +733,9 @@ packages:
|
||||
classnames@2.5.1:
|
||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||
|
||||
cliui@6.0.0:
|
||||
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||
|
||||
clsx@2.1.1:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -766,6 +785,10 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decamelize@1.2.0:
|
||||
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -785,6 +808,9 @@ packages:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
dijkstrajs@1.0.3:
|
||||
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||
|
||||
doctrine@2.1.0:
|
||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -806,6 +832,9 @@ packages:
|
||||
electron-to-chromium@1.5.393:
|
||||
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
|
||||
|
||||
emoji-regex@8.0.0:
|
||||
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||
|
||||
es-abstract-get@1.0.0:
|
||||
resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -922,6 +951,10 @@ packages:
|
||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||
engines: {node: ^10.12.0 || >=12.0.0}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
find-up@5.0.0:
|
||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -968,6 +1001,10 @@ packages:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
get-caller-file@2.0.5:
|
||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||
engines: {node: 6.* || 8.* || >= 10.*}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1103,6 +1140,10 @@ packages:
|
||||
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-fullwidth-code-point@3.0.0:
|
||||
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
is-generator-function@1.1.2:
|
||||
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1210,6 +1251,10 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
locate-path@5.0.0:
|
||||
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
locate-path@6.0.0:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1298,14 +1343,26 @@ packages:
|
||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
p-limit@2.3.0:
|
||||
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-locate@4.1.0:
|
||||
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
p-locate@5.0.0:
|
||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
p-try@2.2.0:
|
||||
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
parent-module@1.0.1:
|
||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -1328,6 +1385,10 @@ packages:
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
pngjs@5.0.0:
|
||||
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
possible-typed-array-names@1.1.0:
|
||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1355,6 +1416,11 @@ packages:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
qrcode@1.5.4:
|
||||
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
hasBin: true
|
||||
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
@@ -1445,6 +1511,13 @@ packages:
|
||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
require-directory@2.1.1:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-main-filename@2.0.0:
|
||||
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||
|
||||
resolve-from@4.0.0:
|
||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||
engines: {node: '>=4'}
|
||||
@@ -1505,6 +1578,9 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
set-blocking@2.0.0:
|
||||
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1549,6 +1625,10 @@ packages:
|
||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
string.prototype.matchall@4.0.12:
|
||||
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1641,6 +1721,9 @@ packages:
|
||||
peerDependencies:
|
||||
react: '>=16.14.0'
|
||||
|
||||
undici-types@8.3.0:
|
||||
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||
|
||||
update-browserslist-db@1.2.3:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -1705,6 +1788,9 @@ packages:
|
||||
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
which-module@2.0.1:
|
||||
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||
|
||||
which-typed-array@1.1.22:
|
||||
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -1718,12 +1804,27 @@ packages:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
y18n@4.0.3:
|
||||
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
yargs@15.4.1:
|
||||
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -2160,8 +2261,16 @@ snapshots:
|
||||
|
||||
'@types/lodash@4.17.24': {}
|
||||
|
||||
'@types/node@26.1.1':
|
||||
dependencies:
|
||||
undici-types: 8.3.0
|
||||
|
||||
'@types/prop-types@15.7.15': {}
|
||||
|
||||
'@types/qrcode@1.5.6':
|
||||
dependencies:
|
||||
'@types/node': 26.1.1
|
||||
|
||||
'@types/react-dom@18.3.7(@types/react@18.3.31)':
|
||||
dependencies:
|
||||
'@types/react': 18.3.31
|
||||
@@ -2183,7 +2292,7 @@ snapshots:
|
||||
|
||||
'@ungap/structured-clone@1.3.3': {}
|
||||
|
||||
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
|
||||
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@26.1.1))':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
|
||||
@@ -2191,7 +2300,7 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||
'@types/babel__core': 7.20.5
|
||||
react-refresh: 0.17.0
|
||||
vite: 5.4.21
|
||||
vite: 5.4.21(@types/node@26.1.1)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -2325,6 +2434,8 @@ snapshots:
|
||||
|
||||
callsites@3.1.0: {}
|
||||
|
||||
camelcase@5.3.1: {}
|
||||
|
||||
caniuse-lite@1.0.30001806: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
@@ -2334,6 +2445,12 @@ snapshots:
|
||||
|
||||
classnames@2.5.1: {}
|
||||
|
||||
cliui@6.0.0:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
wrap-ansi: 6.2.0
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
@@ -2380,6 +2497,8 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decamelize@1.2.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
deepmerge@2.2.1: {}
|
||||
@@ -2398,6 +2517,8 @@ snapshots:
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
dijkstrajs@1.0.3: {}
|
||||
|
||||
doctrine@2.1.0:
|
||||
dependencies:
|
||||
esutils: 2.0.3
|
||||
@@ -2423,6 +2544,8 @@ snapshots:
|
||||
|
||||
electron-to-chromium@1.5.393: {}
|
||||
|
||||
emoji-regex@8.0.0: {}
|
||||
|
||||
es-abstract-get@1.0.0:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
@@ -2676,6 +2799,11 @@ snapshots:
|
||||
dependencies:
|
||||
flat-cache: 3.2.0
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
path-exists: 4.0.0
|
||||
|
||||
find-up@5.0.0:
|
||||
dependencies:
|
||||
locate-path: 6.0.0
|
||||
@@ -2732,6 +2860,8 @@ snapshots:
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
|
||||
get-intrinsic@1.3.0:
|
||||
dependencies:
|
||||
call-bind-apply-helpers: 1.0.2
|
||||
@@ -2884,6 +3014,8 @@ snapshots:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
|
||||
is-fullwidth-code-point@3.0.0: {}
|
||||
|
||||
is-generator-function@1.1.2:
|
||||
dependencies:
|
||||
call-bound: 1.0.4
|
||||
@@ -2991,6 +3123,10 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
locate-path@5.0.0:
|
||||
dependencies:
|
||||
p-locate: 4.1.0
|
||||
|
||||
locate-path@6.0.0:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
@@ -3087,14 +3223,24 @@ snapshots:
|
||||
object-keys: 1.1.1
|
||||
safe-push-apply: 1.0.0
|
||||
|
||||
p-limit@2.3.0:
|
||||
dependencies:
|
||||
p-try: 2.2.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
|
||||
p-locate@4.1.0:
|
||||
dependencies:
|
||||
p-limit: 2.3.0
|
||||
|
||||
p-locate@5.0.0:
|
||||
dependencies:
|
||||
p-limit: 3.1.0
|
||||
|
||||
p-try@2.2.0: {}
|
||||
|
||||
parent-module@1.0.1:
|
||||
dependencies:
|
||||
callsites: 3.1.0
|
||||
@@ -3109,6 +3255,8 @@ snapshots:
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
pngjs@5.0.0: {}
|
||||
|
||||
possible-typed-array-names@1.1.0: {}
|
||||
|
||||
postcss@8.5.20:
|
||||
@@ -3135,6 +3283,12 @@ snapshots:
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
qrcode@1.5.4:
|
||||
dependencies:
|
||||
dijkstrajs: 1.0.3
|
||||
pngjs: 5.0.0
|
||||
yargs: 15.4.1
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
react-aria@3.50.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
@@ -3255,6 +3409,10 @@ snapshots:
|
||||
gopd: 1.2.0
|
||||
set-function-name: 2.0.2
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-main-filename@2.0.0: {}
|
||||
|
||||
resolve-from@4.0.0: {}
|
||||
|
||||
resolve@2.0.0-next.7:
|
||||
@@ -3366,6 +3524,8 @@ snapshots:
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
set-blocking@2.0.0: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -3429,6 +3589,12 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
internal-slot: 1.1.0
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
is-fullwidth-code-point: 3.0.0
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
string.prototype.matchall@4.0.12:
|
||||
dependencies:
|
||||
call-bind: 1.0.9
|
||||
@@ -3556,6 +3722,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
undici-types@8.3.0: {}
|
||||
|
||||
update-browserslist-db@1.2.3(browserslist@4.28.6):
|
||||
dependencies:
|
||||
browserslist: 4.28.6
|
||||
@@ -3572,12 +3740,13 @@ snapshots:
|
||||
|
||||
validator@13.15.35: {}
|
||||
|
||||
vite@5.4.21:
|
||||
vite@5.4.21(@types/node@26.1.1):
|
||||
dependencies:
|
||||
esbuild: 0.21.5
|
||||
postcss: 8.5.20
|
||||
rollup: 4.62.2
|
||||
optionalDependencies:
|
||||
'@types/node': 26.1.1
|
||||
fsevents: 2.3.3
|
||||
|
||||
warning@4.0.3:
|
||||
@@ -3615,6 +3784,8 @@ snapshots:
|
||||
is-weakmap: 2.0.2
|
||||
is-weakset: 2.0.4
|
||||
|
||||
which-module@2.0.1: {}
|
||||
|
||||
which-typed-array@1.1.22:
|
||||
dependencies:
|
||||
available-typed-arrays: 1.0.7
|
||||
@@ -3631,10 +3802,37 @@ snapshots:
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
string-width: 4.2.3
|
||||
strip-ansi: 6.0.1
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
y18n@4.0.3: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yargs-parser@18.1.3:
|
||||
dependencies:
|
||||
camelcase: 5.3.1
|
||||
decamelize: 1.2.0
|
||||
|
||||
yargs@15.4.1:
|
||||
dependencies:
|
||||
cliui: 6.0.0
|
||||
decamelize: 1.2.0
|
||||
find-up: 4.1.0
|
||||
get-caller-file: 2.0.5
|
||||
require-directory: 2.1.1
|
||||
require-main-filename: 2.0.0
|
||||
set-blocking: 2.0.0
|
||||
string-width: 4.2.3
|
||||
which-module: 2.0.1
|
||||
y18n: 4.0.3
|
||||
yargs-parser: 18.1.3
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yup@1.7.1:
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 998 B |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -19,6 +19,7 @@ import ManageBranches from './pages/ManageBranchesScreen/ManageBranchesScreen';
|
||||
import ManageGroups from './pages/ManageGroupsScreen/ManageGroupsScreen';
|
||||
import ManageStations from './pages/ManageStationScreen/ManageStationScreen';
|
||||
import ManageDisplays from './pages/ManageDisplays/ManageDisplays';
|
||||
import ManageAdsScreen from './pages/ManageAdsScreen/ManageAdsScreen';
|
||||
import ManageUsers from './pages/UserManagingScreen/UserManagingScreen';
|
||||
import ViewQueues from './pages/ViewBranchQueues/ViewBranchQueues';
|
||||
import { ROLES } from './constants.js';
|
||||
@@ -55,6 +56,10 @@ export default function App() {
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageDisplays />
|
||||
</AuthGuard>} />
|
||||
<Route exact path="/:tenantCode/manage/ads" element={
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageAdsScreen />
|
||||
</AuthGuard>} />
|
||||
<Route exact path="/:tenantCode/manage/stations" element={
|
||||
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||
<ManageStations />
|
||||
@@ -121,6 +126,12 @@ export default function App() {
|
||||
buttonColor="var(--light-blue)"
|
||||
url={`/${user.tenantCode}/manage/displays`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="Manage advertisements"
|
||||
backgroundColor="var(--light-blue)"
|
||||
buttonColor="var(--dark-blue)"
|
||||
url={`/${user.tenantCode}/manage/ads`}
|
||||
/>
|
||||
<HomePageCard
|
||||
title="Manage groups"
|
||||
backgroundColor="var(--light-blue)"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useContext } from 'react';
|
||||
import { Button } from 'react-bootstrap';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { lastPathPart } from '../../utils/StringUtils.js';
|
||||
import profileImage from '../../../assets/profile-user.png'
|
||||
|
||||
import './Header.css';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
@@ -35,28 +34,31 @@ export default function Header() {
|
||||
return (
|
||||
<>
|
||||
<header className="main-header">
|
||||
<h2 className="header-logo" onClick={ goHome }>BBQMS</h2>
|
||||
<h2 className="header-logo" onClick={goHome}>
|
||||
MyKOPKB QMS-Admin
|
||||
</h2>
|
||||
|
||||
<div className="header-logout">
|
||||
{ !!user && (
|
||||
<button className="header-logout-btn" onClick={ handleLogout }>
|
||||
{!!user && (
|
||||
<button
|
||||
className="header-logout-btn"
|
||||
onClick={handleLogout}
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
) }
|
||||
</div>
|
||||
|
||||
<div className="header-profile" onClick={ () => navigate('/profile') }>
|
||||
<img src={ profileImage } className="header-profile-png" alt="Profile image" />
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{ showBackButton && (
|
||||
<Button variant="secondary"
|
||||
className="mt-2 px-4"
|
||||
onClick={ goHome }>
|
||||
|
||||
{showBackButton && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="mt-2 px-4"
|
||||
onClick={goHome}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
) }
|
||||
)}
|
||||
</>
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
export const SERVER_URL = 'http://localhost:8080';
|
||||
|
||||
export const CUSTOMER_APP_URL =
|
||||
import.meta.env.VITE_CUSTOMER_APP_URL ?? 'http://localhost:3000';
|
||||
|
||||
export const ROLES = {
|
||||
ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
|
||||
ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { getToken, setToken } from '../utils/session.js';
|
||||
|
||||
/**
|
||||
* Multipart upload helper. Do not set Content-Type manually — the browser
|
||||
* must add the multipart boundary.
|
||||
*/
|
||||
export async function uploadFormData(url, formData, method = 'POST') {
|
||||
const headers = new Headers();
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.append('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (res.ok) {
|
||||
const newToken = res.headers.get('Auth-Token');
|
||||
if (newToken) {
|
||||
setToken(newToken);
|
||||
}
|
||||
}
|
||||
|
||||
return { data, success: res.ok };
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { SERVER_URL } from '../../constants';
|
||||
import { getIsTfa, getUserData, setIsTfa } from '../../utils/session.js';
|
||||
import "./AdminProfile.css"
|
||||
|
||||
export default function AdminProfile(){
|
||||
export default function AdminProfile() {
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
const [isQRCodeEnabled, setIsQRCodeEnabled] = useState(false);
|
||||
const [qrCodeSrc, setQrCodeSrc] = useState('');
|
||||
@@ -18,40 +18,40 @@ export default function AdminProfile(){
|
||||
setIsQRCodeEnabled(isTfa);
|
||||
}, []);
|
||||
|
||||
const handleSaveChanges = async () =>{
|
||||
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
|
||||
const handleSaveChanges = async () => {
|
||||
const url = `${SERVER_URL}/api/v1/auth/tfa`;
|
||||
const { data, success } = await fetchData(url, 'PUT', {
|
||||
isTfa: isChecked
|
||||
});
|
||||
setIsTfa(isChecked);
|
||||
setIsQRCodeEnabled(isChecked);
|
||||
if(!isChecked){
|
||||
if (!isChecked) {
|
||||
setQrCodeSrc('');
|
||||
}
|
||||
if(success){
|
||||
if (success) {
|
||||
let message = 'Success: Your changes have been successfully submitted.';
|
||||
if(isChecked){
|
||||
if (isChecked) {
|
||||
message = message + '\nPlease scan QR code.';
|
||||
}
|
||||
alert(message);
|
||||
}else{
|
||||
} else {
|
||||
alert('An error occurred. Please try again.');
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckBoxChange = () =>{
|
||||
const handleCheckBoxChange = () => {
|
||||
setIsChecked(!isChecked);
|
||||
}
|
||||
|
||||
const handleGenerateQRCode = () =>{
|
||||
if(isQRCodeEnabled){
|
||||
const url = `${ SERVER_URL }/api/v1/auth/tfa?email=${userData.email}`;
|
||||
const handleGenerateQRCode = () => {
|
||||
if (isQRCodeEnabled) {
|
||||
const url = `${SERVER_URL}/api/v1/auth/tfa?email=${userData.email}`;
|
||||
fetchData(url, 'GET')
|
||||
.then(({ data, success }) => {
|
||||
if (success) {
|
||||
setQrCodeSrc(data.qrCode);
|
||||
}
|
||||
});
|
||||
.then(({ data, success }) => {
|
||||
if (success) {
|
||||
setQrCodeSrc(data.qrCode);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,17 +59,17 @@ export default function AdminProfile(){
|
||||
<div id="account-settings">
|
||||
<h1>Account settings</h1>
|
||||
<div id="check-box">
|
||||
<form>
|
||||
{/* <form>
|
||||
<label htmlFor="2fa">Use two-factor authentication:</label>
|
||||
<input type="checkbox" id="2fa" name="2fa" checked={isChecked} onChange={handleCheckBoxChange}></input>
|
||||
</form>
|
||||
</form> */}
|
||||
</div>
|
||||
<div id="QR-code">
|
||||
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode}/>
|
||||
{/* <div id="QR-code">
|
||||
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode} />
|
||||
<img src={qrCodeSrc}></img>
|
||||
</div>
|
||||
</div> */}
|
||||
<div>
|
||||
<input type="submit" value="Save changes" onClick={handleSaveChanges}/>
|
||||
<input type="submit" value="Save changes" onClick={handleSaveChanges} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
.ad-tv-preview {
|
||||
text-align: left;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid #334155;
|
||||
background: #0f172a;
|
||||
color: #f8fafc;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.ad-tv-preview__chrome {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.ad-tv-preview__label {
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.ad-tv-preview__ratio {
|
||||
font-size: 0.8rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.ad-tv-preview__stage {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: #1e293b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ad-tv-preview__media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.ad-tv-preview__empty {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.95rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ad-tv-preview__caption {
|
||||
margin: 0.65rem 0 0;
|
||||
text-align: center;
|
||||
font-size: 0.95rem;
|
||||
color: #cbd5e1;
|
||||
}
|
||||
@@ -0,0 +1,597 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Button, Form, Modal, Table } from 'react-bootstrap';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { uploadFormData } from '../../fetching/uploadFormData.js';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import './ManageAdsScreen.css';
|
||||
|
||||
const styles = {
|
||||
primaryButton: {
|
||||
backgroundColor: '#548CA8',
|
||||
borderColor: '#548CA8',
|
||||
},
|
||||
infoButton: {
|
||||
backgroundColor: '#548CA8',
|
||||
color: 'white',
|
||||
borderColor: '#548CA8',
|
||||
},
|
||||
modalHeader: {
|
||||
backgroundColor: '#334257',
|
||||
color: 'white',
|
||||
},
|
||||
};
|
||||
|
||||
const ACCEPTED_TYPES = 'image/jpeg,image/png,image/webp,image/gif,video/mp4,video/webm';
|
||||
|
||||
/** Matches branch TV ad box (see BranchDisplayPage `.branch-display__ad-stage`). */
|
||||
const AD_DISPLAY_SPEC = {
|
||||
aspectRatio: '16:10',
|
||||
recommendedSize: '1920 × 1200 px',
|
||||
minSize: '1280 × 800 px',
|
||||
};
|
||||
|
||||
function AdDisplayGuidelines() {
|
||||
return (
|
||||
<div
|
||||
className="text-start small rounded border p-3 mb-3"
|
||||
style={{ backgroundColor: '#f8fafc', borderColor: '#cbd5e1' }}
|
||||
>
|
||||
<strong>TV display size guide</strong>
|
||||
<ul className="mb-0 mt-2 ps-3">
|
||||
<li>
|
||||
Aspect ratio: <strong>{AD_DISPLAY_SPEC.aspectRatio}</strong> (width : height)
|
||||
</li>
|
||||
<li>
|
||||
Recommended resolution: <strong>{AD_DISPLAY_SPEC.recommendedSize}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Minimum for clear TV: <strong>{AD_DISPLAY_SPEC.minSize}</strong>
|
||||
</li>
|
||||
<li>
|
||||
Use landscape media that fills the frame. Other ratios will show empty bars
|
||||
or look cropped on the branch display.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function mediaUrlFromAd(ad) {
|
||||
if (!ad?.mediaUrl) return null;
|
||||
return ad.mediaUrl.startsWith('http')
|
||||
? ad.mediaUrl
|
||||
: `${SERVER_URL}${ad.mediaUrl}`;
|
||||
}
|
||||
|
||||
function isVideoFile(file) {
|
||||
return Boolean(file?.type?.startsWith('video/'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mimics the branch TV advertisement panel (16:10 stage).
|
||||
*/
|
||||
function AdTvPreview({ src, isVideo, title, emptyLabel = 'Select a file to preview' }) {
|
||||
return (
|
||||
<div className="ad-tv-preview">
|
||||
<div className="ad-tv-preview__chrome">
|
||||
<span className="ad-tv-preview__label">Branch TV preview</span>
|
||||
<span className="ad-tv-preview__ratio">{AD_DISPLAY_SPEC.aspectRatio}</span>
|
||||
</div>
|
||||
<div className="ad-tv-preview__stage">
|
||||
{!src ? (
|
||||
<p className="ad-tv-preview__empty">{emptyLabel}</p>
|
||||
) : isVideo ? (
|
||||
<video
|
||||
key={src}
|
||||
className="ad-tv-preview__media"
|
||||
src={src}
|
||||
controls
|
||||
muted
|
||||
playsInline
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={src}
|
||||
className="ad-tv-preview__media"
|
||||
src={src}
|
||||
alt={title || 'Advertisement preview'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{title ? <p className="ad-tv-preview__caption">{title}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ManageAdsScreen() {
|
||||
const { tenantCode } = useParams();
|
||||
const [ads, setAds] = useState([]);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showEdit, setShowEdit] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [selectedAd, setSelectedAd] = useState(null);
|
||||
|
||||
const [file, setFile] = useState(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [durationSeconds, setDurationSeconds] = useState(10);
|
||||
const [sortOrder, setSortOrder] = useState('');
|
||||
const [active, setActive] = useState(true);
|
||||
|
||||
const url = `${SERVER_URL}/api/v1/ads/${encodeURIComponent(tenantCode)}`;
|
||||
|
||||
const uploadObjectUrl = useMemo(() => {
|
||||
if (!file) return null;
|
||||
return URL.createObjectURL(file);
|
||||
}, [file]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (uploadObjectUrl) {
|
||||
URL.revokeObjectURL(uploadObjectUrl);
|
||||
}
|
||||
};
|
||||
}, [uploadObjectUrl]);
|
||||
|
||||
const loadAds = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchData(url, 'GET');
|
||||
if (!response.success) {
|
||||
setErrorMessage('Failed to load advertisements.');
|
||||
return;
|
||||
}
|
||||
setAds(Array.isArray(response.data) ? response.data : []);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage('Failed to load advertisements.');
|
||||
}
|
||||
}, [url]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAds();
|
||||
}, [loadAds]);
|
||||
|
||||
function resetUploadForm() {
|
||||
setFile(null);
|
||||
setTitle('');
|
||||
setDurationSeconds(10);
|
||||
setSortOrder('');
|
||||
setActive(true);
|
||||
}
|
||||
|
||||
function openEdit(ad) {
|
||||
setSelectedAd(ad);
|
||||
setTitle(ad.title ?? '');
|
||||
setDurationSeconds(ad.durationSeconds ?? 10);
|
||||
setSortOrder(String(ad.sortOrder ?? 0));
|
||||
setActive(Boolean(ad.active));
|
||||
setShowEdit(true);
|
||||
}
|
||||
|
||||
function openPreview(ad) {
|
||||
setSelectedAd(ad);
|
||||
setShowPreview(true);
|
||||
}
|
||||
|
||||
async function handleUpload(event) {
|
||||
event.preventDefault();
|
||||
if (!file) {
|
||||
setErrorMessage('Choose an image or video file to upload.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (title.trim()) {
|
||||
formData.append('title', title.trim());
|
||||
}
|
||||
formData.append('durationSeconds', String(durationSeconds || 10));
|
||||
formData.append('active', String(active));
|
||||
if (sortOrder !== '' && !Number.isNaN(Number(sortOrder))) {
|
||||
formData.append('sortOrder', String(Number(sortOrder)));
|
||||
}
|
||||
|
||||
const response = await uploadFormData(url, formData, 'POST');
|
||||
if (!response.success) {
|
||||
setErrorMessage(response.data?.message || 'Failed to upload advertisement.');
|
||||
return;
|
||||
}
|
||||
|
||||
setShowUpload(false);
|
||||
resetUploadForm();
|
||||
await loadAds();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage('Failed to upload advertisement.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!selectedAd) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchData(`${url}/${selectedAd.id}`, 'PUT', {
|
||||
title: title.trim() || null,
|
||||
durationSeconds: Number(durationSeconds) || 10,
|
||||
sortOrder: sortOrder === '' ? null : Number(sortOrder),
|
||||
active,
|
||||
});
|
||||
if (!response.success) {
|
||||
setErrorMessage(response.data?.message || 'Failed to update advertisement.');
|
||||
return;
|
||||
}
|
||||
setShowEdit(false);
|
||||
setSelectedAd(null);
|
||||
await loadAds();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage('Failed to update advertisement.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!selectedAd) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetchData(`${url}/${selectedAd.id}`, 'DELETE');
|
||||
if (!response.success) {
|
||||
setErrorMessage(response.data?.message || 'Failed to delete advertisement.');
|
||||
return;
|
||||
}
|
||||
setShowDelete(false);
|
||||
setSelectedAd(null);
|
||||
await loadAds();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
setErrorMessage('Failed to delete advertisement.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<h2>Manage Advertisements</h2>
|
||||
<p className="text-muted mb-2">
|
||||
Ads are shared across all branches for this company.
|
||||
</p>
|
||||
<div className="mx-auto mb-3" style={{ maxWidth: 640 }}>
|
||||
<AdDisplayGuidelines />
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={styles.primaryButton}
|
||||
className="mb-3"
|
||||
onClick={() => {
|
||||
resetUploadForm();
|
||||
setShowUpload(true);
|
||||
}}
|
||||
>
|
||||
Upload Ad
|
||||
</Button>
|
||||
|
||||
<Table striped bordered hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Preview</th>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Order</th>
|
||||
<th>Duration (s)</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ads.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-muted">
|
||||
No advertisements yet.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
ads.map((ad) => (
|
||||
<tr key={ad.id}>
|
||||
<td style={{ width: 120 }}>
|
||||
{ad.mediaType === 'IMAGE' ? (
|
||||
<img
|
||||
src={mediaUrlFromAd(ad)}
|
||||
alt={ad.title || ad.fileName}
|
||||
style={{
|
||||
maxWidth: 100,
|
||||
maxHeight: 60,
|
||||
objectFit: 'cover',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => openPreview(ad)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="link"
|
||||
className="p-0"
|
||||
onClick={() => openPreview(ad)}
|
||||
>
|
||||
Video
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
<td>{ad.title || ad.fileName}</td>
|
||||
<td>{ad.mediaType}</td>
|
||||
<td>{ad.sortOrder}</td>
|
||||
<td>{ad.durationSeconds}</td>
|
||||
<td>{ad.active ? 'Yes' : 'No'}</td>
|
||||
<td>
|
||||
<Button
|
||||
variant="info"
|
||||
style={styles.infoButton}
|
||||
onClick={() => openPreview(ad)}
|
||||
>
|
||||
Preview
|
||||
</Button>{' '}
|
||||
<Button
|
||||
variant="info"
|
||||
style={styles.infoButton}
|
||||
onClick={() => openEdit(ad)}
|
||||
>
|
||||
Edit
|
||||
</Button>{' '}
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
setSelectedAd(ad);
|
||||
setShowDelete(true);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
<Modal
|
||||
show={showUpload}
|
||||
onHide={() => {
|
||||
setShowUpload(false);
|
||||
resetUploadForm();
|
||||
}}
|
||||
size="lg"
|
||||
>
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>UPLOAD ADVERTISEMENT</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Form onSubmit={handleUpload}>
|
||||
<Modal.Body>
|
||||
<AdDisplayGuidelines />
|
||||
<AdTvPreview
|
||||
src={uploadObjectUrl}
|
||||
isVideo={isVideoFile(file)}
|
||||
title={title.trim() || file?.name}
|
||||
/>
|
||||
<Form.Group className="mb-3 mt-3">
|
||||
<Form.Label>File (image or video)</Form.Label>
|
||||
<Form.Control
|
||||
type="file"
|
||||
accept={ACCEPTED_TYPES}
|
||||
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
|
||||
required
|
||||
/>
|
||||
<Form.Text className="text-muted">
|
||||
Best fit: {AD_DISPLAY_SPEC.aspectRatio},{' '}
|
||||
{AD_DISPLAY_SPEC.recommendedSize}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Duration seconds (images)</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={1}
|
||||
value={durationSeconds}
|
||||
onChange={(event) => setDurationSeconds(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Sort order</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value)}
|
||||
placeholder="Auto if empty"
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Check
|
||||
type="switch"
|
||||
id="upload-active"
|
||||
label="Active"
|
||||
checked={active}
|
||||
onChange={(event) => setActive(event.target.checked)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowUpload(false);
|
||||
resetUploadForm();
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
style={styles.primaryButton}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Uploading…' : 'Upload'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal show={showEdit} onHide={() => setShowEdit(false)} size="lg">
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>EDIT ADVERTISEMENT</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Form onSubmit={handleEdit}>
|
||||
<Modal.Body>
|
||||
{selectedAd ? (
|
||||
<AdTvPreview
|
||||
src={mediaUrlFromAd(selectedAd)}
|
||||
isVideo={selectedAd.mediaType === 'VIDEO'}
|
||||
title={title.trim() || selectedAd.fileName}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Group className="mb-3 mt-3">
|
||||
<Form.Label>Title</Form.Label>
|
||||
<Form.Control
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Duration seconds (images)</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
min={1}
|
||||
value={durationSeconds}
|
||||
onChange={(event) => setDurationSeconds(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group className="mb-3">
|
||||
<Form.Label>Sort order</Form.Label>
|
||||
<Form.Control
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Check
|
||||
type="switch"
|
||||
id="edit-active"
|
||||
label="Active"
|
||||
checked={active}
|
||||
onChange={(event) => setActive(event.target.checked)}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setShowEdit(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
style={styles.primaryButton}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Saving…' : 'Save'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
show={showPreview}
|
||||
onHide={() => {
|
||||
setShowPreview(false);
|
||||
setSelectedAd(null);
|
||||
}}
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>TV DISPLAY PREVIEW</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
{selectedAd ? (
|
||||
<AdTvPreview
|
||||
src={mediaUrlFromAd(selectedAd)}
|
||||
isVideo={selectedAd.mediaType === 'VIDEO'}
|
||||
title={selectedAd.title || selectedAd.fileName}
|
||||
/>
|
||||
) : null}
|
||||
<p className="text-muted small mt-3 mb-0 text-center">
|
||||
This matches the advertisement panel on the branch waiting-area TV
|
||||
({AD_DISPLAY_SPEC.aspectRatio}).
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowPreview(false);
|
||||
setSelectedAd(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<Modal show={showDelete} onHide={() => setShowDelete(false)}>
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>CONFIRMATION</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>
|
||||
Delete advertisement{' '}
|
||||
<strong>{selectedAd?.title || selectedAd?.fileName}</strong>?
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setShowDelete(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="danger" onClick={handleDelete} disabled={loading}>
|
||||
{loading ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
show={errorMessage !== ''}
|
||||
onHide={() => setErrorMessage('')}
|
||||
backdrop="static"
|
||||
keyboard={false}
|
||||
>
|
||||
<Modal.Header closeButton style={{ backgroundColor: '#dc3545', color: 'white' }}>
|
||||
<Modal.Title>ERROR</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<p>{errorMessage}</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={() => setErrorMessage('')}>
|
||||
Close
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,11 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import {
|
||||
downloadBranchQr,
|
||||
generateBranchQrDataUrl,
|
||||
getBranchTicketUrl,
|
||||
} from '../../utils/branchQr.js';
|
||||
|
||||
const styles = {
|
||||
primaryButton: {
|
||||
@@ -30,6 +35,9 @@ const ManageBranchesScreen = () => {
|
||||
const [newStationName, setNewStationName] = useState('');
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [qrModalBranch, setQrModalBranch] = useState(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||
const [qrLoading, setQrLoading] = useState(false);
|
||||
const { tenantCode } = useParams();
|
||||
|
||||
const url = `${SERVER_URL}/api/v1/branches/${tenantCode}`;
|
||||
@@ -154,6 +162,42 @@ const ManageBranchesScreen = () => {
|
||||
setSelectedBranchIndex(index);
|
||||
};
|
||||
|
||||
const handleShowQr = async (branch) => {
|
||||
setQrModalBranch(branch);
|
||||
setQrDataUrl('');
|
||||
setQrLoading(true);
|
||||
try {
|
||||
const dataUrl = await generateBranchQrDataUrl(tenantCode, branch.id);
|
||||
setQrDataUrl(dataUrl);
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setQrModalBranch(null);
|
||||
setErrorMessage('Failed to generate branch QR code.');
|
||||
} finally {
|
||||
setQrLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadQr = async () => {
|
||||
if (!qrModalBranch) return;
|
||||
try {
|
||||
await downloadBranchQr({
|
||||
tenantCode,
|
||||
branchId: qrModalBranch.id,
|
||||
branchName: qrModalBranch.name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
setErrorMessage('Failed to download branch QR code.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseQrModal = () => {
|
||||
setQrModalBranch(null);
|
||||
setQrDataUrl('');
|
||||
setQrLoading(false);
|
||||
};
|
||||
|
||||
const confirmDeleteBranch = async () => {
|
||||
const branchId = manageBranches[selectedBranchIndex].id;
|
||||
const urlToDelete = `${url}/${branchId}`;
|
||||
@@ -221,6 +265,7 @@ const ManageBranchesScreen = () => {
|
||||
<td>{branch.tellerStations ? branch.tellerStations.map(station => station.name).join(', ') : '-'}</td>
|
||||
<td>
|
||||
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
|
||||
<Button variant="info" style={styles.infoButton} onClick={() => handleShowQr(branch)}>QR</Button>{' '}
|
||||
<Button variant="danger" onClick={() => handleDeleteBranch(index)}>Delete</Button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -270,6 +315,46 @@ const ManageBranchesScreen = () => {
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<Modal show={qrModalBranch !== null} onHide={handleCloseQrModal}>
|
||||
<Modal.Header closeButton style={styles.modalHeader}>
|
||||
<Modal.Title>
|
||||
BRANCH QR{qrModalBranch ? ` — ${qrModalBranch.name}` : ''}
|
||||
</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className="text-center">
|
||||
{qrLoading ? (
|
||||
<p>Generating QR…</p>
|
||||
) : qrDataUrl ? (
|
||||
<>
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt={`QR code for ${qrModalBranch?.name}`}
|
||||
style={{ width: 280, height: 280, maxWidth: '100%' }}
|
||||
/>
|
||||
<p className="mt-3 mb-0 text-break small text-muted">
|
||||
{qrModalBranch
|
||||
? getBranchTicketUrl(tenantCode, qrModalBranch.id)
|
||||
: ''}
|
||||
</p>
|
||||
<p className="mt-2 mb-0 small">
|
||||
Print this QR and place it at the branch. Customers scan it to choose a service.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="secondary" onClick={handleCloseQrModal}>Close</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={styles.primaryButton}
|
||||
onClick={handleDownloadQr}
|
||||
disabled={!qrDataUrl || qrLoading}
|
||||
>
|
||||
Download PNG
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
<Modal show={deleteConfirmation} onHide={() => setDeleteConfirmation(false)}>
|
||||
<Modal.Header closeButton style={{ backgroundColor: '#334257', color: 'white' }}>
|
||||
<Modal.Title>CONFIRMATION</Modal.Title>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import QRCode from 'qrcode';
|
||||
import { CUSTOMER_APP_URL } from '../constants.js';
|
||||
|
||||
const DEFAULT_QR_SECRET = 'qms-branch-qr-v1';
|
||||
|
||||
function getQrSecret() {
|
||||
return import.meta.env.VITE_QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
|
||||
}
|
||||
|
||||
function toBase64Url(bytes) {
|
||||
let binary = '';
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function fromBase64Url(token) {
|
||||
const padded = token.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padLength = (4 - (padded.length % 4)) % 4;
|
||||
const base64 = padded + '='.repeat(padLength);
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function xorBytes(bytes, secret) {
|
||||
const key = new TextEncoder().encode(secret);
|
||||
return bytes.map((byte, index) => byte ^ key[index % key.length]);
|
||||
}
|
||||
|
||||
/** Opaque token so QR URLs don't expose tenant code / branch id. */
|
||||
export function encodeBranchQrToken(tenantCode, branchId, secret = getQrSecret()) {
|
||||
const plain = `v1:${String(tenantCode).trim().toUpperCase()}:${Number(branchId)}`;
|
||||
const plainBytes = new TextEncoder().encode(plain);
|
||||
return toBase64Url(xorBytes(plainBytes, secret));
|
||||
}
|
||||
|
||||
export function decodeBranchQrToken(token, secret = getQrSecret()) {
|
||||
try {
|
||||
const plain = new TextDecoder().decode(xorBytes(fromBase64Url(token), secret));
|
||||
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
|
||||
if (!match) return null;
|
||||
return {
|
||||
tenantCode: match[1],
|
||||
branchId: Number(match[2]),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getBranchTicketUrl(tenantCode, branchId) {
|
||||
const base = CUSTOMER_APP_URL.replace(/\/$/, '');
|
||||
const token = encodeBranchQrToken(tenantCode, branchId);
|
||||
return `${base}/q/${token}`;
|
||||
}
|
||||
|
||||
export async function generateBranchQrDataUrl(tenantCode, branchId) {
|
||||
return QRCode.toDataURL(getBranchTicketUrl(tenantCode, branchId), {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 2,
|
||||
width: 1024,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return String(value)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'branch';
|
||||
}
|
||||
|
||||
export function downloadDataUrl(dataUrl, filename) {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataUrl;
|
||||
link.download = filename;
|
||||
link.click();
|
||||
}
|
||||
|
||||
export async function downloadBranchQr({ tenantCode, branchId, branchName }) {
|
||||
const dataUrl = await generateBranchQrDataUrl(tenantCode, branchId);
|
||||
downloadDataUrl(dataUrl, `qr-${slugify(branchName)}-${branchId}.png`);
|
||||
return dataUrl;
|
||||
}
|
||||
Reference in New Issue
Block a user