DONE: queue management for customer side to get queue number, add logo to teller display, advertisement module

This commit is contained in:
ISMAIL MASSERAN
2026-07-23 10:50:35 +08:00
parent a671f7ad5c
commit 9bc06f1d5b
72 changed files with 13919 additions and 182 deletions
+15 -12
View File
@@ -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>
+2
View File
@@ -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",
+203 -5
View File
@@ -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

+1
View File
@@ -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"}
+11
View File
@@ -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>
) }
)}
</>
);
}
}
+3
View File
@@ -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>
+93
View File
@@ -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;
}