DONE: queue management for customer side to get queue number, add logo to teller display, advertisement module
@@ -10,9 +10,12 @@
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.7",
|
||||
"@expo/metro-runtime": "~3.1.3",
|
||||
"@expo/vector-icons": "^14.0.4",
|
||||
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||
"@react-native-community/masked-view": "^0.1.11",
|
||||
"@react-native/assets-registry": "0.73.1",
|
||||
"@react-navigation/bottom-tabs": "^6.5.20",
|
||||
"@react-navigation/native": "^6.1.17",
|
||||
"@react-navigation/stack": "^6.3.29",
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
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;
|
||||
}
|
||||
@@ -30,9 +30,6 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 15 KiB |
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Customer Queue",
|
||||
description: "Take a queue ticket for your branch and service",
|
||||
title: "MyKOPKB QMS-Customer App",
|
||||
description: "MyKOPKB QMS-Customer App",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
|
||||
import { decodeBranchQrToken } from "@/lib/branchToken";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
token: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function BranchQrPage({ params }: PageProps) {
|
||||
const { token } = await params;
|
||||
const payload = decodeBranchQrToken(token);
|
||||
|
||||
if (!payload) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
|
||||
<p className="text-zinc-600">
|
||||
This branch QR link is not valid. Scan again or enter a company code.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
|
||||
>
|
||||
Enter company code
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||
<CustomerTicketFlow
|
||||
initialTenantCode={payload.tenantCode}
|
||||
initialBranchId={payload.branchId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import Link from "next/link";
|
||||
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{
|
||||
tenantCode: string;
|
||||
branchId: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function BranchTicketPage({ params }: PageProps) {
|
||||
const { tenantCode, branchId: branchIdParam } = await params;
|
||||
const branchId = Number(branchIdParam);
|
||||
|
||||
if (!Number.isFinite(branchId)) {
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
|
||||
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
|
||||
<p className="text-zinc-600">
|
||||
This branch QR link is not valid. Scan again or enter a company code.
|
||||
</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
|
||||
>
|
||||
Enter company code
|
||||
</Link>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||
<CustomerTicketFlow
|
||||
initialTenantCode={tenantCode}
|
||||
initialBranchId={branchId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { FormEvent, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Branch,
|
||||
Service,
|
||||
@@ -10,21 +11,134 @@ import {
|
||||
getBranchServices,
|
||||
getBranches,
|
||||
getTenant,
|
||||
getTicketsForDevice,
|
||||
} from "@/lib/api";
|
||||
import { getDeviceToken } from "@/lib/device";
|
||||
|
||||
type Step = "tenant" | "branch" | "service" | "ticket";
|
||||
|
||||
export default function CustomerTicketFlow() {
|
||||
const [step, setStep] = useState<Step>("tenant");
|
||||
const [tenantCode, setTenantCode] = useState("DFLT");
|
||||
type Props = {
|
||||
initialTenantCode?: string;
|
||||
initialBranchId?: number;
|
||||
};
|
||||
|
||||
function pickLatestTicket(
|
||||
tickets: Ticket[],
|
||||
branchId?: number
|
||||
): Ticket | null {
|
||||
const scoped =
|
||||
branchId != null
|
||||
? tickets.filter((item) => item.branch.id === branchId)
|
||||
: tickets;
|
||||
if (scoped.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [...scoped].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)[0];
|
||||
}
|
||||
|
||||
export default function CustomerTicketFlow({
|
||||
initialTenantCode,
|
||||
initialBranchId,
|
||||
}: Props) {
|
||||
const fromQr =
|
||||
initialTenantCode != null &&
|
||||
initialTenantCode !== "" &&
|
||||
initialBranchId != null &&
|
||||
Number.isFinite(initialBranchId);
|
||||
|
||||
const [step, setStep] = useState<Step>(fromQr ? "service" : "tenant");
|
||||
const [tenantCode, setTenantCode] = useState(
|
||||
initialTenantCode?.trim().toUpperCase() ?? ""
|
||||
);
|
||||
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||
const [branches, setBranches] = useState<Branch[]>([]);
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
|
||||
const [ticket, setTicket] = useState<Ticket | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const bootstrapped = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (bootstrapped.current) return;
|
||||
bootstrapped.current = true;
|
||||
|
||||
async function bootstrap() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const deviceToken = getDeviceToken();
|
||||
if (deviceToken) {
|
||||
const responses = await getTicketsForDevice(deviceToken);
|
||||
const latest = pickLatestTicket(
|
||||
responses.map((response) => response.ticket),
|
||||
fromQr ? initialBranchId : undefined
|
||||
);
|
||||
if (latest) {
|
||||
setTicket(latest);
|
||||
setSelectedBranch({
|
||||
id: latest.branch.id,
|
||||
name: latest.branch.name,
|
||||
tellerStations: latest.branch.tellerStations ?? [],
|
||||
});
|
||||
setStep("ticket");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// No saved ticket — continue into the normal flow.
|
||||
}
|
||||
|
||||
if (!fromQr) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const code = initialTenantCode!.trim().toUpperCase();
|
||||
const branchId = initialBranchId!;
|
||||
|
||||
try {
|
||||
const [tenantData, branchData] = await Promise.all([
|
||||
getTenant(code),
|
||||
getBranches(code),
|
||||
]);
|
||||
const branch = branchData.find((item) => item.id === branchId);
|
||||
if (!branch) {
|
||||
setError(
|
||||
"This branch was not found. Scan the QR again or enter a company code."
|
||||
);
|
||||
setTenant(tenantData);
|
||||
setBranches(branchData);
|
||||
setTenantCode(code);
|
||||
setStep("branch");
|
||||
return;
|
||||
}
|
||||
|
||||
const branchServices = await getBranchServices(code, branch.id);
|
||||
setTenant(tenantData);
|
||||
setBranches(branchData);
|
||||
setTenantCode(code);
|
||||
setSelectedBranch(branch);
|
||||
setServices(branchServices);
|
||||
setStep("service");
|
||||
} catch {
|
||||
setError(
|
||||
"Could not open this branch link. Scan the QR again or enter a company code."
|
||||
);
|
||||
setStep("tenant");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
}, [fromQr, initialTenantCode, initialBranchId]);
|
||||
|
||||
async function handleTenantSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -84,6 +198,7 @@ export default function CustomerTicketFlow() {
|
||||
deviceToken: getDeviceToken(),
|
||||
});
|
||||
setTicket(response.ticket);
|
||||
setSelectedBranch(response.ticket.branch);
|
||||
setStep("ticket");
|
||||
} catch {
|
||||
setError(
|
||||
@@ -94,16 +209,66 @@ export default function CustomerTicketFlow() {
|
||||
}
|
||||
}
|
||||
|
||||
function resetFlow() {
|
||||
setStep(tenant ? "branch" : "tenant");
|
||||
setSelectedBranch(null);
|
||||
setServices([]);
|
||||
async function resetFlow() {
|
||||
setTicket(null);
|
||||
setError(null);
|
||||
|
||||
if (fromQr && initialTenantCode && initialBranchId != null) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const code = initialTenantCode.trim().toUpperCase();
|
||||
const [tenantData, branchData] = await Promise.all([
|
||||
getTenant(code),
|
||||
getBranches(code),
|
||||
]);
|
||||
const branch = branchData.find((item) => item.id === initialBranchId);
|
||||
if (!branch) {
|
||||
setTenant(tenantData);
|
||||
setBranches(branchData);
|
||||
setTenantCode(code);
|
||||
setStep("branch");
|
||||
return;
|
||||
}
|
||||
const branchServices = await getBranchServices(code, branch.id);
|
||||
setTenant(tenantData);
|
||||
setBranches(branchData);
|
||||
setTenantCode(code);
|
||||
setSelectedBranch(branch);
|
||||
setServices(branchServices);
|
||||
setStep("service");
|
||||
} catch {
|
||||
setError("Could not reload services. Try scanning the QR again.");
|
||||
setStep("tenant");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedBranch && tenant) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const branchServices = await getBranchServices(
|
||||
tenant.code,
|
||||
selectedBranch.id
|
||||
);
|
||||
setServices(branchServices);
|
||||
setStep("service");
|
||||
} catch {
|
||||
setError("Could not load services for this branch.");
|
||||
setStep("branch");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setStep(tenant ? "branch" : "tenant");
|
||||
}
|
||||
|
||||
function startOver() {
|
||||
setStep("tenant");
|
||||
setTenantCode("");
|
||||
setTenant(null);
|
||||
setBranches([]);
|
||||
setSelectedBranch(null);
|
||||
@@ -112,18 +277,21 @@ export default function CustomerTicketFlow() {
|
||||
setError(null);
|
||||
}
|
||||
|
||||
const welcomeCopy = fromQr
|
||||
? "Pilih perkhidmatan untuk mendapatkan nombor."
|
||||
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
|
||||
|
||||
return (
|
||||
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12">
|
||||
<header className="space-y-2">
|
||||
<p className="text-sm tracking-wide text-zinc-500 uppercase">
|
||||
Queue Management
|
||||
Sistem Nombor Giliran
|
||||
</p>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900">
|
||||
{tenant?.name ?? "Get a ticket"}
|
||||
{tenant?.name ?? "Dapatkan tiket"}
|
||||
</h1>
|
||||
<p className="text-base text-zinc-600">
|
||||
{tenant?.welcomeMessage ??
|
||||
"Enter your company code, pick a branch and service, then take a number."}
|
||||
{tenant?.welcomeMessage ?? welcomeCopy}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -133,27 +301,31 @@ export default function CustomerTicketFlow() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{fromQr && loading && step === "service" && !selectedBranch ? (
|
||||
<p className="text-sm text-zinc-600">Loading perkhidmatan branch…</p>
|
||||
) : null}
|
||||
|
||||
{step === "tenant" ? (
|
||||
<form onSubmit={handleTenantSubmit} className="space-y-4">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-medium text-zinc-700">
|
||||
Company code
|
||||
Kod syarikat
|
||||
</span>
|
||||
<input
|
||||
value={tenantCode}
|
||||
onChange={(event) => setTenantCode(event.target.value)}
|
||||
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 outline-none focus:border-zinc-900"
|
||||
placeholder="e.g. DFLT"
|
||||
placeholder="Kod syarikat"
|
||||
autoComplete="off"
|
||||
disabled={loading}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || !tenantCode.trim()}
|
||||
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? "Loading…" : "Continue"}
|
||||
{loading ? "Loading…" : "Lanjutkan"}
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
@@ -196,25 +368,35 @@ export default function CustomerTicketFlow() {
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-medium text-zinc-900">
|
||||
Service at {selectedBranch.name}
|
||||
Perkhidmatan di Cawangan {selectedBranch.name}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("branch");
|
||||
setSelectedBranch(null);
|
||||
setServices([]);
|
||||
setError(null);
|
||||
}}
|
||||
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
{fromQr ? (
|
||||
<Link
|
||||
href="/"
|
||||
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
Masukkan kod perkhidmatan
|
||||
</Link>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep("branch");
|
||||
setSelectedBranch(null);
|
||||
setServices([]);
|
||||
setError(null);
|
||||
}}
|
||||
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
|
||||
>
|
||||
Kembali
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{services.length === 0 ? (
|
||||
<p className="text-sm text-zinc-600">
|
||||
No services are assigned to this branch yet. Link services via a
|
||||
branch group in the admin app, then try again.
|
||||
Tiada perkhidmatan yang ditugaskan kepada branch ini. Hubungkan
|
||||
perkhidmatan melalui grup branch dalam aplikasi admin, kemudian
|
||||
cuba lagi.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
@@ -228,7 +410,7 @@ export default function CustomerTicketFlow() {
|
||||
>
|
||||
<span>{service.name}</span>
|
||||
<span className="text-sm text-zinc-500">
|
||||
{loading ? "…" : "Get number"}
|
||||
{loading ? "…" : "Dapatkan nombor"}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
@@ -241,7 +423,7 @@ export default function CustomerTicketFlow() {
|
||||
{step === "ticket" && ticket ? (
|
||||
<section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center">
|
||||
<p className="text-sm tracking-wide text-zinc-500 uppercase">
|
||||
Your ticket number
|
||||
Nombor tiket anda
|
||||
</p>
|
||||
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
|
||||
{ticket.number}
|
||||
@@ -255,7 +437,7 @@ export default function CustomerTicketFlow() {
|
||||
onClick={resetFlow}
|
||||
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white"
|
||||
>
|
||||
Get another ticket
|
||||
Dapatkan tiket lain
|
||||
</button>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -80,3 +80,9 @@ export function createTicket(input: {
|
||||
export function getTicketById(ticketId: number | string) {
|
||||
return request<Ticket>(`/api/v1/tickets/${ticketId}`);
|
||||
}
|
||||
|
||||
export function getTicketsForDevice(deviceToken: string) {
|
||||
return request<TicketResponse[]>(
|
||||
`/api/v1/tickets/devices/${encodeURIComponent(deviceToken)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
const DEFAULT_QR_SECRET = "qms-branch-qr-v1";
|
||||
|
||||
function getQrSecret() {
|
||||
return process.env.QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
|
||||
}
|
||||
|
||||
function toBase64Url(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
bytes.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function fromBase64Url(token: string) {
|
||||
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: Uint8Array, secret: string) {
|
||||
const key = new TextEncoder().encode(secret);
|
||||
return bytes.map((byte, index) => byte ^ key[index % key.length]);
|
||||
}
|
||||
|
||||
export type BranchQrPayload = {
|
||||
tenantCode: string;
|
||||
branchId: number;
|
||||
};
|
||||
|
||||
export function encodeBranchQrToken(
|
||||
tenantCode: string,
|
||||
branchId: number,
|
||||
secret = getQrSecret()
|
||||
) {
|
||||
const plain = `v1:${tenantCode.trim().toUpperCase()}:${Number(branchId)}`;
|
||||
const plainBytes = new TextEncoder().encode(plain);
|
||||
return toBase64Url(xorBytes(plainBytes, secret));
|
||||
}
|
||||
|
||||
export function decodeBranchQrToken(
|
||||
token: string,
|
||||
secret = getQrSecret()
|
||||
): BranchQrPayload | null {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export const SERVER_URL =
|
||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||
export const SERVER_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
export const DEVICE_TOKEN_KEY = "qms_device_token";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 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"}
|
||||
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BBQMS Teller 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" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MyKOPKB QMS-Teller App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 43 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"}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import StationIntroPage from './pages/StationIntroPage/StationIntroPage.jsx';
|
||||
import ShowQueuesForTellerPage from './pages/ShowQueuesForTellerPage/ShowQueuesForTellerPage';
|
||||
import Header from './components/Header/Header.jsx';
|
||||
import CurrentTicketPage from './pages/CurrentTicketPage/CurrentTicketPage.jsx';
|
||||
import BranchDisplayPage from './pages/BranchDisplayPage/BranchDisplayPage.jsx';
|
||||
import LoginPage from './pages/LoginPage/LoginPage.jsx';
|
||||
import AuthGuard from './components/AuthGuard/AuthGuard.jsx';
|
||||
import { UserContext } from './context/UserContext.jsx';
|
||||
@@ -13,6 +14,13 @@ import { fetchData } from './fetching/Fetch.js';
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState(() => getUserData());
|
||||
const location = useLocation();
|
||||
const isDisplayRoute = location.pathname.startsWith('/display');
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('display-mode', isDisplayRoute);
|
||||
return () => document.body.classList.remove('display-mode');
|
||||
}, [isDisplayRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
@@ -32,7 +40,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
<Header />
|
||||
{isDisplayRoute ? null : <Header />}
|
||||
<Routes>
|
||||
<Route exact path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
@@ -53,7 +61,11 @@ export default function App() {
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
{/* Public display board for waiting area screens */}
|
||||
{/* Public display boards for waiting area screens */}
|
||||
<Route
|
||||
path="/display/branch/:tenantCode/:branchId"
|
||||
element={<BranchDisplayPage />}
|
||||
/>
|
||||
<Route path="/display/:stationId" element={<CurrentTicketPage />} />
|
||||
</Routes>
|
||||
</UserContext.Provider>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { clearSession } from '../../utils/session.js';
|
||||
import { clearActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate();
|
||||
@@ -10,6 +11,7 @@ export default function Header() {
|
||||
|
||||
function handleLogout() {
|
||||
clearSession();
|
||||
clearActiveStation();
|
||||
setUser(null);
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
@@ -17,7 +19,7 @@ export default function Header() {
|
||||
return (
|
||||
<header className="main-header">
|
||||
<h2 className="header-logo" onClick={() => navigate(user ? '/' : '/login')}>
|
||||
BBQMS Teller
|
||||
MyKOPKB QMS-Teller
|
||||
</h2>
|
||||
{user ? (
|
||||
<div className="header-user">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export const SERVER_URL = 'http://localhost:8080';
|
||||
|
||||
export const GOLD_PRICE_URL =
|
||||
import.meta.env.VITE_GOLD_PRICE_URL ??
|
||||
'https://apiujrah.erahn.com.my/api/harga_emas';
|
||||
|
||||
export const ROLES = {
|
||||
ROLE_USER: 'ROLE_USER',
|
||||
ROLE_SUPER_ADMIN: 'ROLE_SUPER_ADMIN',
|
||||
|
||||
@@ -14,6 +14,12 @@ body {
|
||||
background-color: ghostwhite;
|
||||
}
|
||||
|
||||
body.display-mode {
|
||||
padding: 0;
|
||||
background-color: #0f172a;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* ovdje definisite konstante boje i sl. koje cete koristiti na vise mjesta */
|
||||
--blue: #334257;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
|
||||
function resolveMediaUrl(mediaUrl) {
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.startsWith('http') ? mediaUrl : `${SERVER_URL}${mediaUrl}`;
|
||||
}
|
||||
|
||||
export default function AdCarousel({ tenantCode }) {
|
||||
const [ads, setAds] = useState([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAds = useCallback(async () => {
|
||||
if (!tenantCode) return;
|
||||
try {
|
||||
const response = await fetchData(
|
||||
`${SERVER_URL}/api/v1/ads/${encodeURIComponent(tenantCode)}/active`,
|
||||
'GET'
|
||||
);
|
||||
if (!response.success) {
|
||||
setError('Could not load advertisements.');
|
||||
return;
|
||||
}
|
||||
const next = Array.isArray(response.data) ? response.data : [];
|
||||
setAds(next);
|
||||
setError(null);
|
||||
setIndex((current) => (next.length === 0 ? 0 : current % next.length));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Could not load advertisements.');
|
||||
}
|
||||
}, [tenantCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAds();
|
||||
const interval = setInterval(loadAds, 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadAds]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setIndex((current) => {
|
||||
if (ads.length === 0) return 0;
|
||||
return (current + 1) % ads.length;
|
||||
});
|
||||
}, [ads.length]);
|
||||
|
||||
const currentAd = ads[index] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
clearTimer();
|
||||
if (!currentAd) return undefined;
|
||||
|
||||
if (currentAd.mediaType === 'IMAGE') {
|
||||
const durationMs = Math.max(1, Number(currentAd.durationSeconds) || 10) * 1000;
|
||||
timerRef.current = setTimeout(goNext, durationMs);
|
||||
return clearTimer;
|
||||
}
|
||||
|
||||
// Videos advance on ended; fallback if play fails
|
||||
timerRef.current = setTimeout(goNext, 5 * 60 * 1000);
|
||||
return clearTimer;
|
||||
}, [currentAd, goNext, clearTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
|
||||
|
||||
const onEnded = () => goNext();
|
||||
video.addEventListener('ended', onEnded);
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
const playPromise = video.play();
|
||||
if (playPromise?.catch) {
|
||||
playPromise.catch(() => {
|
||||
// Autoplay blocked — advance after durationSeconds fallback
|
||||
clearTimer();
|
||||
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
|
||||
timerRef.current = setTimeout(goNext, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, [currentAd, goNext, clearTimer]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
<p className="branch-display__empty">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (ads.length === 0) {
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
<p className="branch-display__empty">Tiada pengiklanan aktif.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const src = resolveMediaUrl(currentAd.mediaUrl);
|
||||
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<div className="branch-display__ad-header">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
</div>
|
||||
<div className="branch-display__ad-stage">
|
||||
{currentAd.mediaType === 'VIDEO' ? (
|
||||
<video
|
||||
key={currentAd.id}
|
||||
ref={videoRef}
|
||||
className="branch-display__ad-media"
|
||||
src={src}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={currentAd.id}
|
||||
className="branch-display__ad-media"
|
||||
src={src}
|
||||
alt={currentAd.title || currentAd.fileName || 'Advertisement'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{currentAd.title ? (
|
||||
<p className="branch-display__ad-caption">{currentAd.title}</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
.branch-display {
|
||||
--bd-bg: #071a10;
|
||||
--bd-bg-soft: #0c2416;
|
||||
--bd-surface: #12301f;
|
||||
--bd-surface-raised: #183c28;
|
||||
--bd-border: #245537;
|
||||
--bd-border-strong: #2f6b45;
|
||||
--bd-text: #f3faf5;
|
||||
--bd-muted: #9bc4a8;
|
||||
--bd-muted-strong: #c5e0cf;
|
||||
--bd-accent: #f0d060;
|
||||
--bd-accent-bright: #ffe566;
|
||||
--bd-accent-deep: #d4a017;
|
||||
--bd-green: #3ecf7a;
|
||||
--bd-error-bg: #5c1a1a;
|
||||
--bd-error-text: #fecaca;
|
||||
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem 1.5rem 1rem;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%),
|
||||
radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
|
||||
linear-gradient(165deg, var(--bd-bg-soft) 0%, var(--bd-bg) 55%, #05140c 100%);
|
||||
color: var(--bd-text);
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__sound-enable {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
border: 1px solid var(--bd-accent-deep);
|
||||
background: linear-gradient(180deg, var(--bd-accent-bright) 0%, var(--bd-accent) 100%);
|
||||
color: #1a2e14;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 12px rgba(240, 208, 96, 0.35);
|
||||
}
|
||||
|
||||
.branch-display__header {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.branch-display__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branch-display__logo {
|
||||
height: clamp(2.5rem, 4.5vw, 3.5rem);
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.35rem;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.branch-display__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bd-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.branch-display__error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--bd-error-bg);
|
||||
color: var(--bd-error-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr);
|
||||
gap: 0.85rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.branch-display {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.branch-display__main {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.branch-display__section--waiting {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.branch-display__section--gold {
|
||||
flex: none;
|
||||
min-height: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
.branch-display__queue-column {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__section {
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section--waiting {
|
||||
flex: 0 1 auto;
|
||||
max-height: 28%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section--gold {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section-title {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-muted-strong);
|
||||
}
|
||||
|
||||
.branch-display__stations {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.branch-display__station-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.65rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
||||
border: 1px solid var(--bd-border);
|
||||
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.branch-display__station-name {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bd-muted);
|
||||
}
|
||||
|
||||
.branch-display__ticket-number {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 3.2vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--bd-accent-bright);
|
||||
text-shadow: 0 0 24px rgba(255, 229, 102, 0.25);
|
||||
}
|
||||
|
||||
.branch-display__ticket-number--idle {
|
||||
color: #4a7a5c;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.branch-display__station-service {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bd-muted-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branch-display__waiting-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-header .branch-display__section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-count {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bd-accent);
|
||||
}
|
||||
|
||||
.branch-display__empty {
|
||||
margin: 0;
|
||||
color: var(--bd-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__waiting-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.branch-display__waiting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--bd-surface);
|
||||
border: 1px solid var(--bd-border);
|
||||
border-left: 3px solid var(--bd-green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-number {
|
||||
font-size: clamp(1.1rem, 1.8vw, 1.5rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bd-accent);
|
||||
}
|
||||
|
||||
.branch-display__waiting-service {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bd-muted-strong);
|
||||
}
|
||||
|
||||
.branch-display__ad-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 0.65rem;
|
||||
border-radius: 0.6rem;
|
||||
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
||||
border: 1px solid var(--bd-border);
|
||||
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__ad-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__ad-header .branch-display__section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.branch-display__ad-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 0.4rem;
|
||||
overflow: hidden;
|
||||
background: var(--bd-bg);
|
||||
border: 1px solid var(--bd-border);
|
||||
}
|
||||
|
||||
.branch-display__ad-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: var(--bd-bg);
|
||||
}
|
||||
|
||||
.branch-display__ad-caption {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bd-muted-strong);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branch-display__section--gold .branch-display__waiting-header {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.branch-display__gold-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--bd-border);
|
||||
background: var(--bd-surface);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table-wrap::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__gold-table th,
|
||||
.branch-display__gold-table td {
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--bd-border);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.branch-display__gold-table th {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-accent);
|
||||
font-weight: 600;
|
||||
background: var(--bd-bg-soft);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.branch-display__gold-table td:nth-child(3) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--bd-accent-bright);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.branch-display__gold-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table tbody tr:hover td {
|
||||
background: rgba(62, 207, 122, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js';
|
||||
import {
|
||||
hasNewServingCall,
|
||||
playQueueCallSound,
|
||||
servingSnapshot,
|
||||
unlockQueueCallSound,
|
||||
} from '../../utils/queueCallSound.js';
|
||||
import AdCarousel from './AdCarousel.jsx';
|
||||
import './BranchDisplayPage.css';
|
||||
|
||||
function formatGoldPrice(value) {
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount)) return value ?? '—';
|
||||
return amount.toLocaleString('en-MY', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export default function BranchDisplayPage() {
|
||||
const { tenantCode, branchId } = useParams();
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [stations, setStations] = useState([]);
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [goldPrices, setGoldPrices] = useState([]);
|
||||
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
|
||||
const [goldError, setGoldError] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [soundReady, setSoundReady] = useState(false);
|
||||
const goldTableWrapRef = useRef(null);
|
||||
const previousServingRef = useRef(null);
|
||||
|
||||
const loadDisplay = useCallback(async () => {
|
||||
if (!tenantCode || !branchId) return;
|
||||
|
||||
try {
|
||||
const [stationsRes, queueRes, branchesRes] = await Promise.all([
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/stations/${encodeURIComponent(tenantCode)}/${branchId}`,
|
||||
'GET'
|
||||
),
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/branches/${encodeURIComponent(tenantCode)}/${branchId}/queue?activeOnly=true&sort=createdAt,asc`,
|
||||
'GET'
|
||||
),
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/branches/${encodeURIComponent(tenantCode)}`,
|
||||
'GET'
|
||||
),
|
||||
]);
|
||||
|
||||
if (!stationsRes.success || !queueRes.success) {
|
||||
setError('Could not load branch display.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setStations(Array.isArray(stationsRes.data) ? stationsRes.data : []);
|
||||
setTickets(Array.isArray(queueRes.data) ? queueRes.data : []);
|
||||
|
||||
if (branchesRes.success && Array.isArray(branchesRes.data)) {
|
||||
const branch = branchesRes.data.find(
|
||||
(item) => Number(item.id) === Number(branchId)
|
||||
);
|
||||
if (branch?.name) {
|
||||
setBranchName(branch.name);
|
||||
}
|
||||
} else if (queueRes.data?.[0]?.branch?.name) {
|
||||
setBranchName(queueRes.data[0].branch.name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Could not load branch display.');
|
||||
}
|
||||
}, [tenantCode, branchId]);
|
||||
|
||||
const loadGoldPrices = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(GOLD_PRICE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gold price request failed (${response.status})`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const rows = Array.isArray(data) ? data : [];
|
||||
setGoldPrices(rows);
|
||||
setGoldUpdatedAt(rows[0]?.tarikhupdate ?? null);
|
||||
setGoldError(null);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setGoldError('Could not load gold prices.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDisplay();
|
||||
const interval = setInterval(loadDisplay, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadDisplay]);
|
||||
|
||||
useEffect(() => {
|
||||
loadGoldPrices();
|
||||
const interval = setInterval(loadGoldPrices, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadGoldPrices]);
|
||||
|
||||
useEffect(() => {
|
||||
const wrap = goldTableWrapRef.current;
|
||||
if (!wrap || goldPrices.length === 0) return undefined;
|
||||
|
||||
let rafId = 0;
|
||||
let pauseUntil = 0;
|
||||
let direction = 1;
|
||||
const speedPxPerSec = 28;
|
||||
const pauseMs = 1600;
|
||||
let lastTs = 0;
|
||||
|
||||
const step = (now) => {
|
||||
if (!lastTs) lastTs = now;
|
||||
const dt = Math.min(now - lastTs, 50);
|
||||
lastTs = now;
|
||||
|
||||
const maxScroll = wrap.scrollHeight - wrap.clientHeight;
|
||||
if (maxScroll <= 2) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
if (now < pauseUntil) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.scrollTop += (speedPxPerSec * dt * direction) / 1000;
|
||||
|
||||
if (direction > 0 && wrap.scrollTop >= maxScroll - 1) {
|
||||
wrap.scrollTop = maxScroll;
|
||||
direction = -1;
|
||||
pauseUntil = now + pauseMs;
|
||||
} else if (direction < 0 && wrap.scrollTop <= 1) {
|
||||
wrap.scrollTop = 0;
|
||||
direction = 1;
|
||||
pauseUntil = now + pauseMs;
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const startId = requestAnimationFrame(() => {
|
||||
wrap.scrollTop = 0;
|
||||
lastTs = 0;
|
||||
rafId = requestAnimationFrame(step);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(startId);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [goldPrices]);
|
||||
|
||||
const servingByStationId = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const ticket of tickets) {
|
||||
if (ticket.station?.id != null) {
|
||||
map.set(Number(ticket.station.id), ticket);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tickets]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = servingSnapshot(tickets);
|
||||
const previous = previousServingRef.current;
|
||||
|
||||
if (hasNewServingCall(previous, next)) {
|
||||
playQueueCallSound();
|
||||
}
|
||||
|
||||
previousServingRef.current = next;
|
||||
}, [tickets]);
|
||||
|
||||
const waiting = useMemo(
|
||||
() => tickets.filter((ticket) => ticket.station == null),
|
||||
[tickets]
|
||||
);
|
||||
|
||||
const sortedStations = useMemo(
|
||||
() => [...stations].sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||
[stations]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="branch-display"
|
||||
onClick={() => {
|
||||
if (!soundReady) {
|
||||
unlockQueueCallSound();
|
||||
setSoundReady(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!soundReady ? (
|
||||
<button
|
||||
type="button"
|
||||
className="branch-display__sound-enable"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
unlockQueueCallSound();
|
||||
setSoundReady(true);
|
||||
}}
|
||||
>
|
||||
Enable call sound
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<header className="branch-display__header">
|
||||
<div className="branch-display__brand">
|
||||
<img
|
||||
className="branch-display__logo"
|
||||
src="/logo-arrahn.jpeg"
|
||||
alt="ar-rahn"
|
||||
/>
|
||||
<h1 className="branch-display__title">
|
||||
Selamat Datang ke Cawangan {branchName}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="branch-display__eyebrow">Sistem Nombor Giliran</p>
|
||||
</header>
|
||||
|
||||
{error ? <p className="branch-display__error">{error}</p> : null}
|
||||
|
||||
<div className="branch-display__main">
|
||||
<div className="branch-display__queue-column">
|
||||
<section className="branch-display__section">
|
||||
<h2 className="branch-display__section-title">Sedang Diproses</h2>
|
||||
{sortedStations.length === 0 ? (
|
||||
<p className="branch-display__empty">Tiada Stesen di Branch ini.</p>
|
||||
) : (
|
||||
<div className="branch-display__stations">
|
||||
{sortedStations.map((station) => {
|
||||
const ticket = servingByStationId.get(Number(station.id));
|
||||
return (
|
||||
<article
|
||||
key={station.id}
|
||||
className="branch-display__station-card"
|
||||
>
|
||||
<p className="branch-display__station-name">
|
||||
{station.name}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
ticket
|
||||
? 'branch-display__ticket-number'
|
||||
: 'branch-display__ticket-number branch-display__ticket-number--idle'
|
||||
}
|
||||
>
|
||||
{ticket ? ticket.number : '—'}
|
||||
</p>
|
||||
<p className="branch-display__station-service">
|
||||
{ticket?.service?.name ?? 'Waiting for next'}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="branch-display__section branch-display__section--waiting">
|
||||
<div className="branch-display__waiting-header">
|
||||
<h2 className="branch-display__section-title">Giliran</h2>
|
||||
<p className="branch-display__waiting-count">
|
||||
{waiting.length} dalam giliran
|
||||
</p>
|
||||
</div>
|
||||
{waiting.length === 0 ? (
|
||||
<p className="branch-display__empty">Tiada nombor dalam giliran.</p>
|
||||
) : (
|
||||
<ul className="branch-display__waiting-list">
|
||||
{waiting.map((ticket) => (
|
||||
<li key={ticket.id} className="branch-display__waiting-item">
|
||||
<span className="branch-display__waiting-number">
|
||||
{ticket.number}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="branch-display__section branch-display__section--gold">
|
||||
<div className="branch-display__waiting-header">
|
||||
<h2 className="branch-display__section-title">Harga Emas</h2>
|
||||
{goldUpdatedAt ? (
|
||||
<p className="branch-display__waiting-count">
|
||||
Dikemas kini pada {goldUpdatedAt}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{goldError ? (
|
||||
<p className="branch-display__error">{goldError}</p>
|
||||
) : null}
|
||||
{!goldError && goldPrices.length === 0 ? (
|
||||
<p className="branch-display__empty">Loading harga emas…</p>
|
||||
) : null}
|
||||
{goldPrices.length > 0 ? (
|
||||
<div
|
||||
ref={goldTableWrapRef}
|
||||
className="branch-display__gold-table-wrap"
|
||||
>
|
||||
<table className="branch-display__gold-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Karat</th>
|
||||
<th>Mutu</th>
|
||||
<th>RM/g</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{goldPrices.map((row) => (
|
||||
<tr
|
||||
key={
|
||||
row.recno ??
|
||||
`${row.karat}-${row.keterangan}`
|
||||
}
|
||||
>
|
||||
<td>{row.karat}</td>
|
||||
<td>{row.keterangan}</td>
|
||||
<td>{formatGoldPrice(row.harga)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AdCarousel tenantCode={tenantCode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Table } from 'react-bootstrap';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { useInterval } from '../../hooks/hooks.jsx';
|
||||
import { fetchData } from '../../fetching/Fetch.js'
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { getActiveStation, saveActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
const styles = {
|
||||
stationRow: {
|
||||
backgroundColor: "#ADD8E6",
|
||||
}
|
||||
backgroundColor: '#ADD8E6',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ShowQueuesForTellerPage() {
|
||||
const url = `${ SERVER_URL }/api/v1/`;
|
||||
const [ tickets, setTickets ] = useState([]);
|
||||
const [ usedUndo, setUsedUndo ] = useState(false)
|
||||
const [ timedOut, setTimedOut ] = useState(false)
|
||||
const url = `${SERVER_URL}/api/v1/`;
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [usedUndo, setUsedUndo] = useState(false);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
const [stationLabel, setStationLabel] = useState(null);
|
||||
|
||||
const { stationId } = useParams();
|
||||
const location = useLocation();
|
||||
const { user } = useContext(UserContext);
|
||||
const tenantCode = user?.tenantCode;
|
||||
|
||||
const { stationId} = useParams()
|
||||
|
||||
useEffect(() => {
|
||||
fetchTicketsForTellerStation();
|
||||
}, [ stationId ]);
|
||||
}, [stationId]);
|
||||
|
||||
useInterval(fetchTicketsForTellerStation, 10000)
|
||||
useEffect(() => {
|
||||
const fromNav = location.state;
|
||||
if (fromNav?.station?.name) {
|
||||
const next = {
|
||||
stationId: Number(stationId),
|
||||
stationName: fromNav.station.name,
|
||||
branchId: fromNav.branch?.id ?? null,
|
||||
branchName: fromNav.branch?.name ?? null,
|
||||
};
|
||||
saveActiveStation(next);
|
||||
setStationLabel(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromSession = getActiveStation(stationId);
|
||||
if (fromSession?.stationName) {
|
||||
setStationLabel(fromSession);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantCode) {
|
||||
setStationLabel({ stationId: Number(stationId), stationName: `Station ${stationId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData(`${url}branches/${encodeURIComponent(tenantCode)}`, 'GET')
|
||||
.then(({ data, success }) => {
|
||||
if (!success || !Array.isArray(data)) {
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const branch of data) {
|
||||
const station = branch.tellerStations?.find(
|
||||
(item) => Number(item.id) === Number(stationId)
|
||||
);
|
||||
if (station) {
|
||||
const next = {
|
||||
stationId: Number(stationId),
|
||||
stationName: station.name,
|
||||
branchId: branch.id,
|
||||
branchName: branch.name,
|
||||
};
|
||||
saveActiveStation(next);
|
||||
setStationLabel(next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
});
|
||||
}, [stationId, tenantCode, location.state, url]);
|
||||
|
||||
useInterval(fetchTicketsForTellerStation, 10000);
|
||||
|
||||
function fetchTicketsForTellerStation() {
|
||||
fetchData(`${ url }stations/${ stationId }/tickets`, 'GET')
|
||||
.then(response => response.data)
|
||||
fetchData(`${url}stations/${stationId}/tickets`, 'GET')
|
||||
.then((response) => response.data)
|
||||
.then(setTickets)
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function advanceQueue() {
|
||||
fetchData(`${ url }teller/advance-queue/${ stationId }`, 'POST')
|
||||
fetchData(`${url}teller/advance-queue/${stationId}`, 'POST')
|
||||
.then(fetchTicketsForTellerStation)
|
||||
.then(() => setUsedUndo(false))
|
||||
.then(timeOutTeller)
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function undoQueue() {
|
||||
fetchData(`${ url }teller/undo-queue/${ stationId }`, 'POST')
|
||||
fetchData(`${url}teller/undo-queue/${stationId}`, 'POST')
|
||||
.then(fetchTicketsForTellerStation)
|
||||
.then(() => setUsedUndo(true))
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function timeOutTeller() {
|
||||
setTimedOut(true)
|
||||
setTimeout(() => setTimedOut(false), 5000)
|
||||
setTimedOut(true);
|
||||
setTimeout(() => setTimedOut(false), 5000);
|
||||
// Can't advance more often than every 5 seconds.
|
||||
}
|
||||
|
||||
const hasAssignedTicket = tickets.some(ticket => ticket.station?.id == stationId)
|
||||
const hasAssignedTicket = tickets.some((ticket) => ticket.station?.id == stationId);
|
||||
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<h1>Queues</h1>
|
||||
<div className="d-flex gap-2 justify-content-center">
|
||||
{stationLabel ? (
|
||||
<p className="mb-3" style={{ fontSize: '1.25rem', color: '#334257' }}>
|
||||
<strong>{stationLabel.stationName}</strong>
|
||||
{stationLabel.branchName ? (
|
||||
<span className="text-muted"> · {stationLabel.branchName}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="d-flex gap-2 justify-content-center flex-wrap">
|
||||
<Button
|
||||
variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ advanceQueue }
|
||||
disabled={ timedOut }
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={advanceQueue}
|
||||
disabled={timedOut}
|
||||
>
|
||||
Advance Queue
|
||||
Nombor Giliran Seterusnya
|
||||
</Button>
|
||||
<Button variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ undoQueue }
|
||||
disabled={ usedUndo || !hasAssignedTicket }>
|
||||
Undo
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={undoQueue}
|
||||
disabled={usedUndo || !hasAssignedTicket}
|
||||
>
|
||||
Patah balik
|
||||
</Button>
|
||||
<Button variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ fetchTicketsForTellerStation }>
|
||||
Refresh
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={fetchTicketsForTellerStation}
|
||||
>
|
||||
Muat semula
|
||||
</Button>
|
||||
<Link to={`/display/${stationId}`} target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
>
|
||||
Current Ticket
|
||||
</Button>
|
||||
<Button variant="secondary">Tiket semasa</Button>
|
||||
</Link>
|
||||
{tenantCode && stationLabel?.branchId ? (
|
||||
<Link
|
||||
to={`/display/branch/${encodeURIComponent(tenantCode)}/${stationLabel.branchId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="secondary">Paparan TV</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={ { marginTop: '20px' } }>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Table striped bordered hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket number</th>
|
||||
<th>Service</th>
|
||||
<th>Creation at</th>
|
||||
<th>Nombor tiket</th>
|
||||
<th>Perkhidmatan</th>
|
||||
<th>Dibuat pada</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map((ticket, index) => {
|
||||
{tickets.map((ticket, index) => {
|
||||
const createdAt = new Date(ticket.createdAt);
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td style={ticket.station?.id == stationId ? styles.stationRow : {}}>{ticket.number}</td>
|
||||
<td
|
||||
style={
|
||||
ticket.station?.id == stationId
|
||||
? styles.stationRow
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{ticket.number}
|
||||
</td>
|
||||
<td>{ticket.service.name}</td>
|
||||
<td>{createdAt.toLocaleString()}</td>
|
||||
</tr>
|
||||
@@ -111,5 +207,5 @@ export default function ShowQueuesForTellerPage() {
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { saveActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
const StationIntroPage = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -77,15 +78,26 @@ const StationIntroPage = () => {
|
||||
|
||||
function handleStationSelect(station) {
|
||||
setSelectedStation(station);
|
||||
navigate(`/teller-queue/${station.id}`);
|
||||
saveActiveStation({
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
branchId: selectedBranch?.id,
|
||||
branchName: selectedBranch?.name,
|
||||
});
|
||||
navigate(`/teller-queue/${station.id}`, {
|
||||
state: {
|
||||
station,
|
||||
branch: selectedBranch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="border p-3" style={{ maxWidth: '500px', margin: '0 auto' }}>
|
||||
<h1 className="mb-2">Select Branch and Station</h1>
|
||||
<h1 className="mb-2">Pilih Cawangan dan Kaunter</h1>
|
||||
{tenantCode ? (
|
||||
<p className="text-muted mb-4">Tenant: {tenantCode}</p>
|
||||
<p className="text-muted mb-4">Syarikat: {tenantCode}</p>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-danger">{error}</p> : null}
|
||||
@@ -98,10 +110,10 @@ const StationIntroPage = () => {
|
||||
disabled={loadingBranches || branches.length === 0}
|
||||
>
|
||||
{loadingBranches
|
||||
? 'Loading branches…'
|
||||
? 'Loading cawangan…'
|
||||
: selectedBranch
|
||||
? selectedBranch.name
|
||||
: 'Select Branch'}
|
||||
? selectedBranch.name
|
||||
: 'Pilih Cawangan'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu style={{ width: '100%' }}>
|
||||
{branches.map((branch) => (
|
||||
@@ -125,10 +137,10 @@ const StationIntroPage = () => {
|
||||
disabled={loadingStations || stations.length === 0}
|
||||
>
|
||||
{loadingStations
|
||||
? 'Loading stations…'
|
||||
? 'Loading kaunter…'
|
||||
: selectedStation
|
||||
? selectedStation.name
|
||||
: 'Select Station'}
|
||||
? selectedStation.name
|
||||
: 'Pilih Kaunter'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu style={{ width: '100%' }}>
|
||||
{stations.map((station) => (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const ACTIVE_STATION_KEY = 'qms_teller_active_station';
|
||||
|
||||
export function saveActiveStation({ stationId, stationName, branchId, branchName }) {
|
||||
sessionStorage.setItem(
|
||||
ACTIVE_STATION_KEY,
|
||||
JSON.stringify({
|
||||
stationId: Number(stationId),
|
||||
stationName,
|
||||
branchId: branchId != null ? Number(branchId) : null,
|
||||
branchName: branchName ?? null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getActiveStation(stationId) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(ACTIVE_STATION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (stationId != null && Number(parsed.stationId) !== Number(stationId)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearActiveStation() {
|
||||
sessionStorage.removeItem(ACTIVE_STATION_KEY);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
const CALL_SOUND_URL = '/sounds/header-sound.mp3';
|
||||
|
||||
let sharedAudio = null;
|
||||
let unlocked = false;
|
||||
|
||||
function getCallAudio() {
|
||||
if (!sharedAudio) {
|
||||
sharedAudio = new Audio(CALL_SOUND_URL);
|
||||
sharedAudio.preload = 'auto';
|
||||
}
|
||||
return sharedAudio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once after a user gesture (click/tap) so later plays are allowed.
|
||||
*/
|
||||
export function unlockQueueCallSound() {
|
||||
if (unlocked) return;
|
||||
try {
|
||||
const audio = getCallAudio();
|
||||
audio.muted = true;
|
||||
const playPromise = audio.play();
|
||||
if (playPromise?.then) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
})
|
||||
.catch(() => {
|
||||
// Still mark unlocked so we keep trying on real calls.
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
});
|
||||
} else {
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
}
|
||||
} catch {
|
||||
unlocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the queue-call notification chime.
|
||||
* Safe to call repeatedly; restarts from the beginning each time.
|
||||
*/
|
||||
export function playQueueCallSound() {
|
||||
try {
|
||||
const audio = getCallAudio();
|
||||
audio.muted = false;
|
||||
audio.currentTime = 0;
|
||||
const playPromise = audio.play();
|
||||
if (playPromise?.catch) {
|
||||
playPromise.catch((error) => {
|
||||
console.warn('Could not play queue call sound:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not play queue call sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable map of stationId -> ticket number for currently serving tickets.
|
||||
*/
|
||||
export function servingSnapshot(tickets) {
|
||||
const map = {};
|
||||
for (const ticket of tickets) {
|
||||
if (ticket?.station?.id != null && ticket?.number != null) {
|
||||
map[String(ticket.station.id)] = String(ticket.number);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any station got a new/different ticket number vs the previous snapshot.
|
||||
*/
|
||||
export function hasNewServingCall(previous, next) {
|
||||
if (!previous) return false;
|
||||
for (const [stationId, number] of Object.entries(next)) {
|
||||
if (previous[stationId] !== number) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||