Dev/v1.0 (#1)

Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
2026-07-23 10:51:04 +08:00
parent 3189e3a1e3
commit 85b31e9528
106 changed files with 19492 additions and 401 deletions
+3
View File
@@ -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",
+10492
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,4 +3,4 @@
"compilerOptions": {
"strict": true
}
}
}
+15 -12
View File
@@ -1,14 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBQMS Admin App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyKOPKB QMS-Admin App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2
View File
@@ -14,6 +14,7 @@
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"formik": "^2.4.5",
"qrcode": "^1.5.4",
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0",
@@ -24,6 +25,7 @@
"yup": "^1.4.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
+203 -5
View File
@@ -20,6 +20,9 @@ importers:
formik:
specifier: ^2.4.5
version: 2.4.9(@types/react@18.3.31)(react@18.3.1)
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^18.2.0
version: 18.3.1
@@ -45,6 +48,9 @@ importers:
specifier: ^1.4.0
version: 1.7.1
devDependencies:
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: ^18.2.64
version: 18.3.31
@@ -53,7 +59,7 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.2.1
version: 4.7.0(vite@5.4.21)
version: 4.7.0(vite@5.4.21(@types/node@26.1.1))
eslint:
specifier: ^8.57.0
version: 8.57.1
@@ -68,7 +74,7 @@ importers:
version: 0.4.26(eslint@8.57.1)
vite:
specifier: ^5.1.6
version: 5.4.21
version: 5.4.21(@types/node@26.1.1)
packages:
@@ -572,9 +578,15 @@ packages:
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
@@ -707,6 +719,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
@@ -717,6 +733,9 @@ packages:
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -766,6 +785,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -785,6 +808,9 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -806,6 +832,9 @@ packages:
electron-to-chromium@1.5.393:
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
es-abstract-get@1.0.0:
resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
engines: {node: '>= 0.4'}
@@ -922,6 +951,10 @@ packages:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -968,6 +1001,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1103,6 +1140,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
@@ -1210,6 +1251,10 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1298,14 +1343,26 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1328,6 +1385,10 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -1355,6 +1416,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1445,6 +1511,13 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1505,6 +1578,9 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -1549,6 +1625,10 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@@ -1641,6 +1721,9 @@ packages:
peerDependencies:
react: '>=16.14.0'
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -1705,6 +1788,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'}
@@ -1718,12 +1804,27 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2160,8 +2261,16 @@ snapshots:
'@types/lodash@4.17.24': {}
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
'@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 26.1.1
'@types/react-dom@18.3.7(@types/react@18.3.31)':
dependencies:
'@types/react': 18.3.31
@@ -2183,7 +2292,7 @@ snapshots:
'@ungap/structured-clone@1.3.3': {}
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@26.1.1))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -2191,7 +2300,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21
vite: 5.4.21(@types/node@26.1.1)
transitivePeerDependencies:
- supports-color
@@ -2325,6 +2434,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001806: {}
chalk@4.1.2:
@@ -2334,6 +2445,12 @@ snapshots:
classnames@2.5.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -2380,6 +2497,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
deep-is@0.1.4: {}
deepmerge@2.2.1: {}
@@ -2398,6 +2517,8 @@ snapshots:
dequal@2.0.3: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -2423,6 +2544,8 @@ snapshots:
electron-to-chromium@1.5.393: {}
emoji-regex@8.0.0: {}
es-abstract-get@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -2676,6 +2799,11 @@ snapshots:
dependencies:
flat-cache: 3.2.0
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -2732,6 +2860,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -2884,6 +3014,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
@@ -2991,6 +3123,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -3087,14 +3223,24 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -3109,6 +3255,8 @@ snapshots:
picocolors@1.1.1: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.5.20:
@@ -3135,6 +3283,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
react-aria@3.50.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -3255,6 +3409,10 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve@2.0.0-next.7:
@@ -3366,6 +3524,8 @@ snapshots:
semver@6.3.1: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -3429,6 +3589,12 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.9
@@ -3556,6 +3722,8 @@ snapshots:
dependencies:
react: 18.3.1
undici-types@8.3.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.6):
dependencies:
browserslist: 4.28.6
@@ -3572,12 +3740,13 @@ snapshots:
validator@13.15.35: {}
vite@5.4.21:
vite@5.4.21(@types/node@26.1.1):
dependencies:
esbuild: 0.21.5
postcss: 8.5.20
rollup: 4.62.2
optionalDependencies:
'@types/node': 26.1.1
fsevents: 2.3.3
warning@4.0.3:
@@ -3615,6 +3784,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.22:
dependencies:
available-typed-arrays: 1.0.7
@@ -3631,10 +3802,37 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
y18n@4.0.3: {}
yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yocto-queue@0.1.0: {}
yup@1.7.1:
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+74 -62
View File
@@ -19,27 +19,29 @@ 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';
import { clearSession, getToken, getUserData } from './utils/session.js';
export default function App() {
const [user, setUser] = useState();
/*
Kada se logiramo, ako vec postoji token u localStorage, provjerimo da li je validan (nije istekao)
Ako je validan, ulogujemo usera, ako nije ocistimo storage od starih podataka
Kada se logiramo, ako vec postoji token u cookie-u, provjerimo da li je validan (nije istekao)
Ako je validan, ulogujemo usera, ako nije ocistimo session
*/
useEffect(() => {
const token = localStorage.getItem('token');
const token = getToken();
if (token) {
const url = `${ SERVER_URL }/api/v1/auth`;
const url = `${SERVER_URL}/api/v1/auth`;
fetchData(url, 'GET')
.then(({ data, success }) => {
.then(({ success }) => {
if (success) {
setUser(JSON.parse(localStorage.getItem('userData')));
setUser(getUserData());
} else {
localStorage.removeItem('token');
localStorage.removeItem('userData');
clearSession();
setUser(null);
}
});
}
@@ -47,152 +49,162 @@ export default function App() {
return (
<>
<UserContext.Provider value={ { user, setUser } }>
<UserContext.Provider value={{ user, setUser }}>
<Header />
<Routes>
<Route exact path="/:tenantCode/manage/displays" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageDisplays />
</AuthGuard> } />
</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] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageStations />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/manage/groups" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageGroups />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/manage/branches" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageBranches />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/companydetails"
element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<CompanyInfoUpdate />
</AuthGuard>
}
element={
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<CompanyInfoUpdate />
</AuthGuard>
}
/>
<Route exact path="/:tenantCode/manage/services" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageServices />
</AuthGuard> }
</AuthGuard>}
/>
<Route exact path="/:tenantCode/manage/users" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageUsers />
</AuthGuard>
} />
<Route exact path="/login" element={ <LoginScreen /> } />
<Route exact path="/login" element={<LoginScreen />} />
<Route exact path="/:tenantCode/manage/admins" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN]}>
<ManageAdmins />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/profile" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<AdminProfile />
</AuthGuard> } />
<Route exact path="/" element={ <LoginScreen /> } />
</AuthGuard>} />
<Route exact path="/" element={<LoginScreen />} />
<Route exact path="/loginauth"
element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<LoginAuth />
</AuthGuard>
}
element={
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<LoginAuth />
</AuthGuard>
}
/>
<Route exact path="/:tenantCode/queues" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ViewQueues />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/home" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<HomePage></HomePage>
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<CanAccess roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<>
{ user && (
{user && (
<>
<HomePageCard
title="Manage displays"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/displays` }
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)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/groups` }
url={`/${user.tenantCode}/manage/groups`}
/>
<HomePageCard
title="Manage branches"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/branches` }
url={`/${user.tenantCode}/manage/branches`}
/>
<HomePageCard
title="Manage services"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/services` }
url={`/${user.tenantCode}/manage/services`}
/>
<HomePageCard
title="Manage teller stations"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/stations` }
url={`/${user.tenantCode}/manage/stations`}
/>
<HomePageCard
title="Manage company details"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/companydetails` }
url={`/${user.tenantCode}/companydetails`}
/>
<HomePageCard
title="View queues"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/queues` }
url={`/${user.tenantCode}/queues`}
/>
</>
) }
)}
</>
</CanAccess>
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN] }>
<CanAccess roles={[ROLES.ROLE_SUPER_ADMIN]}>
<>
{ user && (
{user && (
<>
<HomePageCard title="Manage administrators"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/admins` }></HomePageCard>
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={`/${user.tenantCode}/manage/admins`}></HomePageCard>
<HomePageCard
title="Manage users"
title="Manage Teller"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/users` }
url={`/${user.tenantCode}/manage/users`}
/>
</>
) }
)}
</>
</CanAccess>
<CanAccess roles={ [ROLES.ROLE_BRANCH_ADMIN] }>
{ user && (
<CanAccess roles={[ROLES.ROLE_BRANCH_ADMIN]}>
{user && (
<HomePageCard
title="Manage users"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/users` }
url={`/${user.tenantCode}/manage/users`}
/>
) }
)}
</CanAccess>
</AuthGuard>
} />
<Route path="*" element={ <NotFound /> } />
<Route path="*" element={<NotFound />} />
</Routes>
</UserContext.Provider>
</>
@@ -1,12 +1,12 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getUserData } from '../../utils/session.js';
export default function AuthGuard({ children, roles }) {
const navigate = useNavigate();
useEffect(() => {
const storedUserData = localStorage.getItem('userData');
const user = storedUserData ? JSON.parse(storedUserData) : null;
const user = getUserData();
if (!user) {
navigate('/login');
@@ -23,7 +23,7 @@ export default function AuthGuard({ children, roles }) {
return (
<>
{ children }
{children}
</>
);
@@ -2,20 +2,19 @@ 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';
import { clearSession } from '../../utils/session.js';
export default function Header() {
const navigate = useNavigate();
const { user, setUser } = useContext(UserContext);
function handleLogout() {
localStorage.removeItem('userData');
localStorage.removeItem('token');
clearSession();
setUser(null);
navigate('/');
navigate('/login');
}
const path = window.location.pathname;
@@ -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>
) }
)}
</>
);
}
}
@@ -4,6 +4,7 @@ import { SERVER_URL } from "../../constants.js";
import { fetchData } from '../../fetching/Fetch.js';
import { UserContext } from '../../context/UserContext.jsx';
import { useNavigate } from "react-router-dom";
import { getUserData, setSession } from '../../utils/session.js';
function doSubmit(submittedValues) {
console.log(`Submitted: ${submittedValues.join("")}`);
@@ -126,10 +127,9 @@ export default function LoginAuth() {
dispatch({ type: "VERIFY" });
try {
const storedUserData = localStorage.getItem('userData');
const userData = storedUserData ? JSON.parse(storedUserData) : null;
const userData = getUserData();
if (!userData) {
throw new Error("Email not found in localStorage");
throw new Error("Email not found in session");
}
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
@@ -139,7 +139,11 @@ export default function LoginAuth() {
});
if (success) {
localStorage.setItem('token', data.token);
setSession({
token: data.token,
userData: data.userData,
isTfa: true,
});
setUser(data.userData);
navigate(`/${ data.userData.tenantCode }/home`);
} else {
@@ -5,6 +5,7 @@ import "./LoginForm.css";
import LoginAuth from "../LoginAuth/LoginAuth";
import { Route, Routes, useNavigate, Link } from "react-router-dom";
import { SERVER_URL } from "../../constants.js";
import { setSession } from "../../utils/session.js";
const LoginForm = () => {
const [username, setUsername] = useState("");
@@ -28,7 +29,11 @@ const LoginForm = () => {
return;
}
const data = await response.json();
localStorage.setItem('userData', JSON.stringify(data));
setSession({
userData: data.userData ?? data,
token: data.token,
isTfa: !data.token,
});
navigate('/');
}
@@ -67,9 +72,12 @@ const LoginForm = () => {
const data = await response.json();
localStorage.setItem('userData', JSON.stringify(data));
if (response.ok) {
setSession({
userData: data.userData ?? data,
token: data.token,
isTfa: !data.token,
});
setIsSubmitted(true);
navigate('/');
} else if (response.status === 403) {
+3
View File
@@ -1,5 +1,8 @@
export const SERVER_URL = 'http://localhost:8080';
export const CUSTOMER_APP_URL =
import.meta.env.VITE_CUSTOMER_APP_URL ?? 'http://localhost:3000';
export const ROLES = {
ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
+5 -3
View File
@@ -1,10 +1,12 @@
/*
Koristiti ovu funkciju za fetchanje u buducnosti kad god je to moguce.
*/
import { getToken, setToken } from '../utils/session.js';
export async function fetchData(url, method, body) {
const headers = new Headers();
const token = localStorage.getItem('token');
const token = getToken();
if (token) {
headers.append('Authorization', `Bearer ${ token }`);
}
@@ -27,9 +29,9 @@ export async function fetchData(url, method, body) {
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
const newToken = res.headers.get('Auth-Token');
if (newToken) {
localStorage.setItem('token', newToken);
setToken(newToken);
}
}
return { data: data, success: res.ok };
}
}
@@ -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,6 +4,7 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import { SERVER_URL } from '../../constants.js';
import { UserContext } from '../../context/UserContext.jsx';
import { useNavigate, useParams } from "react-router-dom";
import { getToken } from '../../utils/session.js';
const styles = {
primaryButton: {
@@ -30,22 +31,22 @@ const AdminManageScreen = () => {
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [selectedAdminIndex, setSelectedAdminIndex] = useState(null);
const [token, setToken] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
if (getToken()) {
fetchAdmins();
}
}, []);
useEffect(() => {
if (token) {
fetchAdmins();
}
}, [token]);
const authHeaders = () => {
const token = getToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
const fetchAdmins = async () => {
try {
@@ -54,10 +55,7 @@ const AdminManageScreen = () => {
});
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: requestBody
});
@@ -87,10 +85,7 @@ const AdminManageScreen = () => {
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: JSON.stringify(requestBody)
});
@@ -122,10 +117,7 @@ const AdminManageScreen = () => {
};
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${admins[selectedAdminIndex].id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: JSON.stringify(updatedAdmin),
});
if (response.ok) {
@@ -146,10 +138,7 @@ const AdminManageScreen = () => {
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': token
}
headers: authHeaders(),
});
if (response.ok) {
const updatedAdmins = admins.filter(admin => admin.id !== userId);
@@ -1,59 +1,57 @@
import { useState, useEffect } from 'react';
import { fetchData } from '../../fetching/Fetch.js';
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('');
const [userData, setUserData] = useState('');
useEffect(() => {
const storedUserData = localStorage.getItem('userData');
setUserData(JSON.parse(storedUserData));
const storedIsTfa = localStorage.getItem('isTfa');
let isTfa = JSON.parse(storedIsTfa);
setUserData(getUserData());
const isTfa = getIsTfa();
setIsChecked(isTfa);
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
});
localStorage.setItem('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);
}
});
}
}
@@ -61,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>
);
@@ -1,13 +1,22 @@
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';
import validator from 'validator';
import './LoginScreen.css';
import { useNavigate } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
import { ROLES, SERVER_URL } from '../../constants.js';
import { fetchData } from '../../fetching/Fetch.js';
import { UserContext } from '../../context/UserContext.jsx';
import { clearSession, setSession } from '../../utils/session.js';
const ADMIN_ROLES = [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN];
function hasAdminAccess(userData) {
const roles = Array.isArray(userData?.roles) ? userData.roles : [];
return roles.some((role) => ADMIN_ROLES.includes(role));
}
export default function LoginScreen() {
const navigate = useNavigate();
const { setUser } = useContext(UserContext);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -38,26 +47,42 @@ export default function LoginScreen() {
password: password
});
if (success) {
if (data.userData == undefined) {
localStorage.setItem('userData', JSON.stringify(data));
} else {
localStorage.setItem('userData', JSON.stringify(data.userData));
localStorage.setItem('token', data.token);
setUser(data.userData);
}
setIsSubmitted(true);
if (data.token) {
localStorage.setItem('isTfa', false);
navigate(`/${data.userData.tenantCode}/home`);
} else {
localStorage.setItem('isTfa', true);
navigate('/loginauth');
}
} else {
if (!success || !data) {
setError('Your credentials are incorrect.');
return;
}
const userData = data.userData ?? data;
const token = data.token;
if (!hasAdminAccess(userData)) {
clearSession();
setUser(null);
setError(
'This account is a regular user (ROLE_USER) and cannot access the admin app. Create an admin via Manage administrators.'
);
return;
}
if (!token) {
setSession({ userData, isTfa: true });
setUser(userData);
navigate('/loginauth', { replace: true });
return;
}
if (!userData?.tenantCode) {
setError('Login succeeded but tenant information is missing.');
return;
}
setSession({
userData,
token,
isTfa: false,
});
setUser(userData);
navigate(`/${userData.tenantCode}/home`, { replace: true });
} catch (error) {
console.error('Error:', error);
setError('An error occurred. Please try again.');
@@ -73,11 +98,6 @@ export default function LoginScreen() {
setPassword(event.target.value);
setError('');
};
/*
if (isSubmitted) {
//navigate('/loginAuth');
navigate('/companydetails');
}*/
return (
<div id="login-form">
@@ -92,9 +112,7 @@ export default function LoginScreen() {
value={username}
onChange={handleUsernameChange}
/>
{error && (error.includes('Username') || error.includes('Invalid')) &&
<p className="error">{error}</p>}
{error && (error.includes('credentials')) && <p className="error">{error}</p>}
{error && !error.includes('Password') && <p className="error">{error}</p>}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
@@ -116,4 +134,4 @@ export default function LoginScreen() {
</form>
</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>
@@ -2,8 +2,8 @@ import React, { useState, useEffect } from 'react';
import { Button, Table, Modal, Form } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { SERVER_URL } from '../../constants.js';
import { UserContext } from '../../context/UserContext.jsx';
import { useNavigate, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { getToken } from '../../utils/session.js';
const styles = {
primaryButton: {
@@ -29,34 +29,31 @@ const UserManageScreen = () => {
const [userEmail, setUserEmail] = useState('');
const [userPassword, setUserPassword] = useState('');
const [selectedUserIndex, setSelectedUserIndex] = useState(null);
const [token, setToken] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
if (getToken()) {
fetchUsers();
}
}, []);
useEffect(() => {
if (token) {
fetchUsers();
}
}, [token]);
const authHeaders = () => {
const token = getToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
const fetchUsers = async () => {
try {
const requestBody = JSON.stringify({
roleName: 'ROLE_USER'
});
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: requestBody
});
@@ -84,12 +81,9 @@ const UserManageScreen = () => {
};
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user`, {
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: JSON.stringify(requestBody)
});
@@ -119,12 +113,9 @@ const UserManageScreen = () => {
const updatedUser = {
email: userEmail
};
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${users[selectedUserIndex].id}`, {
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user/${users[selectedUserIndex].id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: JSON.stringify(updatedUser),
});
if (response.ok) {
@@ -144,12 +135,9 @@ const UserManageScreen = () => {
const handleDeleteUser = async (userId) => {
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${userId}`, {
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user/${userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': token
}
headers: authHeaders(),
});
if (response.ok) {
const updatedUsers = users.filter(user => user.id !== userId);
@@ -171,28 +159,32 @@ const UserManageScreen = () => {
return (
<div className="text-center">
<h2>Manage Users</h2>
<h2>Manage Teller</h2>
<p className="text-muted mb-3">
These accounts have ROLE_USER and cannot log into the admin app.
Use Manage administrators to create admin logins.
</p>
<Button variant="primary" style={styles.primaryButton} className="mb-3" onClick={() => { setShowModal(true); setSelectedUserIndex(null); }}>Add User</Button>
<Table striped bordered hover>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Actions</th>
</tr>
<tr>
<th>ID</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr key={index}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
<Button variant="danger" onClick={() => handleDeleteUser(user.id)}>Delete</Button>
</td>
</tr>
))}
{users.map((user, index) => (
<tr key={index}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
<Button variant="danger" onClick={() => handleDeleteUser(user.id)}>Delete</Button>
</td>
</tr>
))}
</tbody>
</Table>
+93
View File
@@ -0,0 +1,93 @@
import QRCode from 'qrcode';
import { CUSTOMER_APP_URL } from '../constants.js';
const DEFAULT_QR_SECRET = 'qms-branch-qr-v1';
function getQrSecret() {
return import.meta.env.VITE_QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
}
function toBase64Url(bytes) {
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function fromBase64Url(token) {
const padded = token.replace(/-/g, '+').replace(/_/g, '/');
const padLength = (4 - (padded.length % 4)) % 4;
const base64 = padded + '='.repeat(padLength);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function xorBytes(bytes, secret) {
const key = new TextEncoder().encode(secret);
return bytes.map((byte, index) => byte ^ key[index % key.length]);
}
/** Opaque token so QR URLs don't expose tenant code / branch id. */
export function encodeBranchQrToken(tenantCode, branchId, secret = getQrSecret()) {
const plain = `v1:${String(tenantCode).trim().toUpperCase()}:${Number(branchId)}`;
const plainBytes = new TextEncoder().encode(plain);
return toBase64Url(xorBytes(plainBytes, secret));
}
export function decodeBranchQrToken(token, secret = getQrSecret()) {
try {
const plain = new TextDecoder().decode(xorBytes(fromBase64Url(token), secret));
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
if (!match) return null;
return {
tenantCode: match[1],
branchId: Number(match[2]),
};
} catch {
return null;
}
}
export function getBranchTicketUrl(tenantCode, branchId) {
const base = CUSTOMER_APP_URL.replace(/\/$/, '');
const token = encodeBranchQrToken(tenantCode, branchId);
return `${base}/q/${token}`;
}
export async function generateBranchQrDataUrl(tenantCode, branchId) {
return QRCode.toDataURL(getBranchTicketUrl(tenantCode, branchId), {
errorCorrectionLevel: 'M',
margin: 2,
width: 1024,
color: {
dark: '#000000',
light: '#ffffff',
},
});
}
function slugify(value) {
return String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'branch';
}
export function downloadDataUrl(dataUrl, filename) {
const link = document.createElement('a');
link.href = dataUrl;
link.download = filename;
link.click();
}
export async function downloadBranchQr({ tenantCode, branchId, branchName }) {
const dataUrl = await generateBranchQrDataUrl(tenantCode, branchId);
downloadDataUrl(dataUrl, `qr-${slugify(branchName)}-${branchId}.png`);
return dataUrl;
}
+124
View File
@@ -0,0 +1,124 @@
const TOKEN_KEY = 'token';
const USER_DATA_KEY = 'userData';
const IS_TFA_KEY = 'isTfa';
/** Matches backend jwt.token-validity-time (PT30M). */
const SESSION_MAX_AGE_SECONDS = 30 * 60;
function getCookie(name) {
const prefix = `${encodeURIComponent(name)}=`;
const parts = document.cookie ? document.cookie.split('; ') : [];
for (const part of parts) {
if (part.startsWith(prefix)) {
return decodeURIComponent(part.slice(prefix.length));
}
}
return null;
}
function setCookie(name, value, maxAgeSeconds = SESSION_MAX_AGE_SECONDS) {
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = [
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
`Path=/`,
`Max-Age=${maxAgeSeconds}`,
`SameSite=Lax`,
secure,
].join('; ');
}
function removeCookie(name) {
document.cookie = `${encodeURIComponent(name)}=; Path=/; Max-Age=0; SameSite=Lax`;
}
function migrateFromLocalStorage(key) {
const legacy = localStorage.getItem(key);
if (legacy == null) {
return null;
}
setCookie(key, legacy);
localStorage.removeItem(key);
return legacy;
}
export function getToken() {
return getCookie(TOKEN_KEY) ?? migrateFromLocalStorage(TOKEN_KEY);
}
export function setToken(token) {
if (token == null || token === '') {
removeCookie(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
return;
}
setCookie(TOKEN_KEY, token);
localStorage.removeItem(TOKEN_KEY);
}
export function getUserData() {
const raw = getCookie(USER_DATA_KEY) ?? migrateFromLocalStorage(USER_DATA_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch {
removeCookie(USER_DATA_KEY);
return null;
}
}
export function setUserData(userData) {
if (userData == null) {
removeCookie(USER_DATA_KEY);
localStorage.removeItem(USER_DATA_KEY);
return;
}
setCookie(USER_DATA_KEY, JSON.stringify(userData));
localStorage.removeItem(USER_DATA_KEY);
}
export function getIsTfa() {
const raw = getCookie(IS_TFA_KEY) ?? migrateFromLocalStorage(IS_TFA_KEY);
if (raw == null) {
return false;
}
try {
return JSON.parse(raw);
} catch {
return raw === 'true';
}
}
export function setIsTfa(isTfa) {
setCookie(IS_TFA_KEY, JSON.stringify(Boolean(isTfa)));
localStorage.removeItem(IS_TFA_KEY);
}
export function setSession({ token, userData, isTfa } = {}) {
if (token !== undefined) {
setToken(token);
}
if (userData !== undefined) {
setUserData(userData);
}
if (isTfa !== undefined) {
setIsTfa(isTfa);
}
}
export function clearSession() {
removeCookie(TOKEN_KEY);
removeCookie(USER_DATA_KEY);
removeCookie(IS_TFA_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_DATA_KEY);
localStorage.removeItem(IS_TFA_KEY);
}
+38
View File
@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+24
View File
@@ -0,0 +1,24 @@
@import "tailwindcss";
:root {
--background: #fafafa;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
html,
body {
min-height: 100%;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "MyKOPKB QMS-Customer App",
description: "MyKOPKB QMS-Customer App",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+9
View File
@@ -0,0 +1,9 @@
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
export default function Home() {
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<CustomerTicketFlow />
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,446 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import {
Branch,
Service,
Tenant,
Ticket,
createTicket,
getBranchServices,
getBranches,
getTenant,
getTicketsForDevice,
} from "@/lib/api";
import { getDeviceToken } from "@/lib/device";
type Step = "tenant" | "branch" | "service" | "ticket";
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(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();
const code = tenantCode.trim().toUpperCase();
if (!code) {
setError("Enter a company code.");
return;
}
setLoading(true);
setError(null);
try {
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
} catch {
setError("Could not find that company. Check the code and try again.");
} finally {
setLoading(false);
}
}
async function handleSelectBranch(branch: Branch) {
if (!tenant) return;
setLoading(true);
setError(null);
setSelectedBranch(branch);
try {
const branchServices = await getBranchServices(tenant.code, branch.id);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not load services for this branch.");
} finally {
setLoading(false);
}
}
async function handleSelectService(service: Service) {
if (!selectedBranch) return;
setLoading(true);
setError(null);
try {
const response = await createTicket({
branchId: selectedBranch.id,
serviceId: service.id,
deviceToken: getDeviceToken(),
});
setTicket(response.ticket);
setSelectedBranch(response.ticket.branch);
setStep("ticket");
} catch {
setError(
"Could not get a ticket number. Make sure this branch has services assigned."
);
} finally {
setLoading(false);
}
}
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);
setServices([]);
setTicket(null);
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">
Sistem Nombor Giliran
</p>
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900">
{tenant?.name ?? "Dapatkan tiket"}
</h1>
<p className="text-base text-zinc-600">
{tenant?.welcomeMessage ?? welcomeCopy}
</p>
</header>
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</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">
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="Kod syarikat"
autoComplete="off"
disabled={loading}
/>
</label>
<button
type="submit"
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…" : "Lanjutkan"}
</button>
</form>
) : null}
{step === "branch" ? (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-medium text-zinc-900">Choose a branch</h2>
<button
type="button"
onClick={startOver}
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Change company
</button>
</div>
{branches.length === 0 ? (
<p className="text-sm text-zinc-600">No branches available.</p>
) : (
<ul className="space-y-2">
{branches.map((branch) => (
<li key={branch.id}>
<button
type="button"
disabled={loading}
onClick={() => handleSelectBranch(branch)}
className="flex w-full items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-3 text-left text-zinc-900 transition hover:border-zinc-400 disabled:opacity-60"
>
<span>{branch.name}</span>
<span className="text-zinc-400"></span>
</button>
</li>
))}
</ul>
)}
</section>
) : null}
{step === "service" && selectedBranch ? (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-medium text-zinc-900">
Perkhidmatan di Cawangan {selectedBranch.name}
</h2>
{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">
Tiada perkhidmatan yang ditugaskan kepada branch ini. Hubungkan
perkhidmatan melalui grup branch dalam aplikasi admin, kemudian
cuba lagi.
</p>
) : (
<ul className="space-y-2">
{services.map((service) => (
<li key={service.id}>
<button
type="button"
disabled={loading}
onClick={() => handleSelectService(service)}
className="flex w-full items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-3 text-left text-zinc-900 transition hover:border-zinc-400 disabled:opacity-60"
>
<span>{service.name}</span>
<span className="text-sm text-zinc-500">
{loading ? "…" : "Dapatkan nombor"}
</span>
</button>
</li>
))}
</ul>
)}
</section>
) : null}
{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">
Nombor tiket anda
</p>
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
{ticket.number}
</p>
<div className="space-y-1 text-sm text-zinc-600">
<p>{ticket.service.name}</p>
<p>{ticket.branch.name}</p>
</div>
<button
type="button"
onClick={resetFlow}
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white"
>
Dapatkan tiket lain
</button>
</section>
) : null}
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+88
View File
@@ -0,0 +1,88 @@
import { SERVER_URL } from "./constants";
export type Tenant = {
id: number;
code: string;
name: string;
welcomeMessage: string;
font: string | null;
logo?: { id: number; base64Logo: string | null } | null;
};
export type Branch = {
id: number;
name: string;
tellerStations: { id: number; name: string }[];
};
export type Service = {
id: number;
name: string;
};
export type Ticket = {
id: number;
number: string;
createdAt: string;
service: Service;
branch: Branch;
station: { id: number; name: string } | null;
};
export type TicketResponse = {
ticket: Ticket;
stations: { id: number; name: string }[];
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${SERVER_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
throw new Error(`Request failed (${response.status}) for ${path}`);
}
return response.json() as Promise<T>;
}
export function getTenant(code: string) {
return request<Tenant>(`/api/v1/tenants/${encodeURIComponent(code)}`);
}
export function getBranches(tenantCode: string) {
return request<Branch[]>(
`/api/v1/branches/${encodeURIComponent(tenantCode)}`
);
}
export function getBranchServices(tenantCode: string, branchId: number) {
return request<Service[]>(
`/api/v1/branches/${encodeURIComponent(tenantCode)}/${branchId}/services`
);
}
export function createTicket(input: {
branchId: number;
serviceId: number;
deviceToken: string;
}) {
return request<TicketResponse>("/api/v1/tickets", {
method: "POST",
body: JSON.stringify(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)}`
);
}
+67
View File
@@ -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;
}
}
+3
View File
@@ -0,0 +1,3 @@
export const SERVER_URL = process.env.NEXT_PUBLIC_API_URL;
export const DEVICE_TOKEN_KEY = "qms_device_token";
+20
View File
@@ -0,0 +1,20 @@
import { DEVICE_TOKEN_KEY } from "./constants";
export function getDeviceToken(): string {
if (typeof window === "undefined") {
return "";
}
const existing = window.localStorage.getItem(DEVICE_TOKEN_KEY);
if (existing) {
return existing;
}
const token =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
window.localStorage.setItem(DEVICE_TOKEN_KEY, token);
return token;
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
devIndicators: false,
};
export default nextConfig;
+26
View File
@@ -0,0 +1,26 @@
{
"name": "customer-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+4102
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 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
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+14 -11
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

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"}
Binary file not shown.
+61 -8
View File
@@ -1,20 +1,73 @@
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
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';
import { clearSession, getToken, getUserData } from './utils/session.js';
import { SERVER_URL } from './constants.js';
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();
if (!token) {
return;
}
fetchData(`${SERVER_URL}/api/v1/auth`, 'GET').then(({ success }) => {
if (success) {
setUser(getUserData());
} else {
clearSession();
setUser(null);
}
});
}, []);
return (
<>
<Header />
<UserContext.Provider value={{ user, setUser }}>
{isDisplayRoute ? null : <Header />}
<Routes>
<Route exact path='/' element={<StationIntroPage />} />
<Route exact path="/teller-queue/:stationId" element={<ShowQueuesForTellerPage />} />
<Route path="/display/:stationId" element={<CurrentTicketPage/>} />
<Route exact path="/login" element={<LoginPage />} />
<Route
exact
path="/"
element={
<AuthGuard>
<StationIntroPage />
</AuthGuard>
}
/>
<Route
exact
path="/teller-queue/:stationId"
element={
<AuthGuard>
<ShowQueuesForTellerPage />
</AuthGuard>
}
/>
{/* Public display boards for waiting area screens */}
<Route
path="/display/branch/:tenantCode/:branchId"
element={<BranchDisplayPage />}
/>
<Route path="/display/:stationId" element={<CurrentTicketPage />} />
</Routes>
</>
</UserContext.Provider>
);
}
@@ -0,0 +1,37 @@
import { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserContext } from '../../context/UserContext.jsx';
import { getUserData } from '../../utils/session.js';
import { ROLES } from '../../constants.js';
const TELLER_ROLES = [
ROLES.ROLE_USER,
ROLES.ROLE_SUPER_ADMIN,
ROLES.ROLE_BRANCH_ADMIN,
];
export default function AuthGuard({ children }) {
const navigate = useNavigate();
const { user: contextUser } = useContext(UserContext);
const [allowed, setAllowed] = useState(false);
useEffect(() => {
const user = contextUser ?? getUserData();
const roles = Array.isArray(user?.roles) ? user.roles : [];
const hasAccess = roles.some((role) => TELLER_ROLES.includes(role));
if (!user || !hasAccess) {
setAllowed(false);
navigate('/login', { replace: true });
return;
}
setAllowed(true);
}, [contextUser, navigate]);
if (!allowed) {
return null;
}
return <>{children}</>;
}
@@ -15,4 +15,25 @@ header.main-header {
margin-top: 7px;
margin-right: auto;
margin-left: 2%;
}
.header-user {
display: flex;
align-items: center;
gap: 12px;
margin-right: 2%;
}
.header-email {
font-size: 14px;
color: #334257;
}
.header-logout-btn {
border: 1px solid #334257;
background: white;
color: #334257;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
}
@@ -1,13 +1,34 @@
import './Header.css';
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();
const { user, setUser } = useContext(UserContext);
function handleLogout() {
clearSession();
clearActiveStation();
setUser(null);
navigate('/login', { replace: true });
}
return (
<header className="main-header">
<h2 className="header-logo" onClick={() => navigate(`/`)}>BBQMS</h2>
<h2 className="header-logo" onClick={() => navigate(user ? '/' : '/login')}>
MyKOPKB QMS-Teller
</h2>
{user ? (
<div className="header-user">
<span className="header-email">{user.email}</span>
<button type="button" className="header-logout-btn" onClick={handleLogout}>
Logout
</button>
</div>
) : null}
</header>
);
}
}
+10
View File
@@ -1 +1,11 @@
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',
ROLE_BRANCH_ADMIN: 'ROLE_BRANCH_ADMIN',
};
@@ -0,0 +1,6 @@
import { createContext } from 'react';
export const UserContext = createContext({
user: null,
setUser: () => {},
});
+8 -7
View File
@@ -1,12 +1,14 @@
/*
Koristiti ovu funkciju za fetchanje u buducnosti kad god je to moguce.
*/
import { getToken, setToken } from '../utils/session.js';
export async function fetchData(url, method, body) {
const headers = new Headers();
const token = localStorage.getItem('token');
const token = getToken();
if (token) {
headers.append('Authorization', `Bearer ${ token }`);
headers.append('Authorization', `Bearer ${token}`);
}
headers.append('Content-Type', 'application/json');
@@ -14,7 +16,7 @@ export async function fetchData(url, method, body) {
const res = await fetch(url, {
method: method || 'GET',
headers: headers,
body: body ? JSON.stringify(body) : null
body: body ? JSON.stringify(body) : null,
});
if (!res) {
@@ -24,12 +26,11 @@ export async function fetchData(url, method, body) {
const data = res.ok && res.body ? await res.json() : null;
if (res.ok) {
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
const newToken = res.headers.get('Auth-Token');
if (newToken) {
localStorage.setItem('token', newToken);
setToken(newToken);
}
}
return { data: data, success: res.ok };
}
return { data, success: res.ok };
}
+6
View File
@@ -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>
);
}
@@ -0,0 +1,67 @@
#teller-login-form {
width: 420px;
max-width: 100%;
margin: 80px auto 40px;
background-color: #334257;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
#teller-login-form h1 {
margin: 0;
padding: 20px 0;
text-align: center;
font-size: 28px;
font-weight: bold;
color: white;
}
#teller-login-form form {
padding: 20px;
background-color: white;
border-radius: 10px;
}
#teller-login-form .form-group {
margin-bottom: 16px;
}
#teller-login-form label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #555;
}
#teller-login-form input {
width: 100%;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
#teller-login-form button {
width: 100%;
margin-top: 8px;
padding: 12px;
border: none;
border-radius: 5px;
background-color: #548ca8;
color: white;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
#teller-login-form button:disabled {
opacity: 0.7;
cursor: default;
}
#teller-login-form .error {
margin: 0 0 12px;
color: #b00020;
font-size: 14px;
}
@@ -0,0 +1,130 @@
import { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ROLES, SERVER_URL } from '../../constants.js';
import { fetchData } from '../../fetching/Fetch.js';
import { UserContext } from '../../context/UserContext.jsx';
import { clearSession, getToken, setSession } from '../../utils/session.js';
import './LoginPage.css';
const TELLER_ROLES = [
ROLES.ROLE_USER,
ROLES.ROLE_SUPER_ADMIN,
ROLES.ROLE_BRANCH_ADMIN,
];
function canAccessTellerApp(userData) {
const roles = Array.isArray(userData?.roles) ? userData.roles : [];
return roles.some((role) => TELLER_ROLES.includes(role));
}
export default function LoginPage() {
const navigate = useNavigate();
const { user, setUser } = useContext(UserContext);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
if (user && getToken()) {
navigate('/', { replace: true });
}
}, [user, navigate]);
async function handleSubmit(event) {
event.preventDefault();
if (!email.trim()) {
setError('Email is required.');
return;
}
if (!password.trim()) {
setError('Password is required.');
return;
}
setLoading(true);
setError('');
try {
const { data, success } = await fetchData(`${SERVER_URL}/api/v1/auth/login`, 'POST', {
email: email.trim(),
password: password.trim(),
});
if (!success || !data) {
setError('Your credentials are incorrect.');
return;
}
const userData = data.userData ?? data;
const token = data.token;
if (!canAccessTellerApp(userData)) {
clearSession();
setUser(null);
setError('This account cannot access the teller app.');
return;
}
if (!token) {
setError('Two-factor login is not supported in the teller app yet. Disable 2FA for this account or use an account without 2FA.');
return;
}
if (!userData?.tenantCode) {
setError('Login succeeded but tenant information is missing.');
return;
}
setSession({ userData, token });
setUser(userData);
navigate('/', { replace: true });
} catch (err) {
console.error(err);
setError('An error occurred. Please try again.');
} finally {
setLoading(false);
}
}
return (
<div id="teller-login-form">
<h1>Teller Login</h1>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
autoComplete="username"
onChange={(event) => {
setEmail(event.target.value);
setError('');
}}
/>
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
autoComplete="current-password"
onChange={(event) => {
setPassword(event.target.value);
setError('');
}}
/>
</div>
{error ? <p className="error">{error}</p> : null}
<button type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</button>
</form>
</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>
)
);
}
@@ -1,73 +1,160 @@
import React, { useState, useEffect } from 'react';
import { Dropdown, Button } from 'react-bootstrap';
import React, { useContext, useEffect, useState } from 'react';
import { Dropdown } from 'react-bootstrap';
import { fetchData } from '../../fetching/Fetch.js';
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();
const { user } = useContext(UserContext);
const [branches, setBranches] = useState([]);
const [selectedBranch, setSelectedBranch] = useState(null);
const [stations, setStations] = useState([]);
const [selectedStation, setSelectedStation] = useState(null);
const [error, setError] = useState('');
const [loadingBranches, setLoadingBranches] = useState(false);
const [loadingStations, setLoadingStations] = useState(false);
const url = `${ SERVER_URL }/api/v1/`;
const tenantCode = user?.tenantCode;
const url = `${SERVER_URL}/api/v1/`;
useEffect(() => {
fetchData(`${ url }branches/DFLT`, 'GET')
.then(response => response.data)
.then(setBranches)
.catch(console.error)
}, []);
if (!tenantCode) {
setBranches([]);
setError('Missing tenant on your account.');
return;
}
setLoadingBranches(true);
setError('');
fetchData(`${url}branches/${encodeURIComponent(tenantCode)}`, 'GET')
.then(({ data, success }) => {
if (!success) {
setBranches([]);
setError('Could not load branches for your tenant.');
return;
}
setBranches(Array.isArray(data) ? data : []);
if (!data?.length) {
setError(`No branches found for tenant ${tenantCode}.`);
}
})
.catch((err) => {
console.error(err);
setError('Could not load branches.');
})
.finally(() => setLoadingBranches(false));
}, [tenantCode]);
function handleBranchSelect(branch) {
setSelectedBranch(branch)
fetchData(`${ url }stations/DFLT/${ branch.id }`, 'GET')
.then(response => response.data)
.then(setStations)
.catch(console.error)
setSelectedBranch(branch);
setSelectedStation(null);
setStations([]);
setLoadingStations(true);
setError('');
fetchData(`${url}stations/${encodeURIComponent(tenantCode)}/${branch.id}`, 'GET')
.then(({ data, success }) => {
if (!success) {
setStations([]);
setError('Could not load stations for this branch.');
return;
}
setStations(Array.isArray(data) ? data : []);
if (!data?.length) {
setError('No stations found for this branch.');
}
})
.catch((err) => {
console.error(err);
setError('Could not load stations.');
})
.finally(() => setLoadingStations(false));
}
function handleStationSelect(station) {
navigate(`/teller-queue/${ station.id }`)
setSelectedStation(station);
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-4">Select Branch and Station</h1>
<h1 className="mb-2">Pilih Cawangan dan Kaunter</h1>
{tenantCode ? (
<p className="text-muted mb-4">Syarikat: {tenantCode}</p>
) : null}
{error ? <p className="text-danger">{error}</p> : null}
<Dropdown>
<Dropdown.Toggle variant="primary" id="dropdown-branch" style={{ width: '100%' }}>
{selectedBranch ? selectedBranch.name : 'Select Branch'}
<Dropdown.Toggle
variant="primary"
id="dropdown-branch"
style={{ width: '100%' }}
disabled={loadingBranches || branches.length === 0}
>
{loadingBranches
? 'Loading cawangan…'
: selectedBranch
? selectedBranch.name
: 'Pilih Cawangan'}
</Dropdown.Toggle>
<Dropdown.Menu style={{ width: '100%' }}>
{branches.map(branch => (
<Dropdown.Item key={branch.id} onClick={() => handleBranchSelect(branch)}>
{branches.map((branch) => (
<Dropdown.Item
key={branch.id}
onClick={() => handleBranchSelect(branch)}
>
{branch.name}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
{selectedBranch && (
{selectedBranch ? (
<div className="mt-3">
<Dropdown>
<Dropdown.Toggle variant="primary" id="dropdown-station" style={{ width: '100%' }}>
{selectedStation ? selectedStation.name : 'Select Station'}
<Dropdown.Toggle
variant="primary"
id="dropdown-station"
style={{ width: '100%' }}
disabled={loadingStations || stations.length === 0}
>
{loadingStations
? 'Loading kaunter…'
: selectedStation
? selectedStation.name
: 'Pilih Kaunter'}
</Dropdown.Toggle>
<Dropdown.Menu style={{ width: '100%' }}>
{stations.map(station => (
<Dropdown.Item key={station.id} onClick={() => handleStationSelect(station)}>
{stations.map((station) => (
<Dropdown.Item
key={station.id}
onClick={() => handleStationSelect(station)}
>
{station.name}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
</div>
)}
) : null}
</div>
</div>
);
@@ -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;
}
+87
View File
@@ -0,0 +1,87 @@
const TOKEN_KEY = 'teller_token';
const USER_DATA_KEY = 'teller_userData';
/** Matches backend jwt.token-validity-time (PT30M). */
const SESSION_MAX_AGE_SECONDS = 30 * 60;
function getCookie(name) {
const prefix = `${encodeURIComponent(name)}=`;
const parts = document.cookie ? document.cookie.split('; ') : [];
for (const part of parts) {
if (part.startsWith(prefix)) {
return decodeURIComponent(part.slice(prefix.length));
}
}
return null;
}
function setCookie(name, value, maxAgeSeconds = SESSION_MAX_AGE_SECONDS) {
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = [
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
'Path=/',
`Max-Age=${maxAgeSeconds}`,
'SameSite=Lax',
secure,
].join('; ');
}
function removeCookie(name) {
document.cookie = `${encodeURIComponent(name)}=; Path=/; Max-Age=0; SameSite=Lax`;
}
export function getToken() {
return getCookie(TOKEN_KEY);
}
export function setToken(token) {
if (token == null || token === '') {
removeCookie(TOKEN_KEY);
return;
}
setCookie(TOKEN_KEY, token);
}
export function getUserData() {
const raw = getCookie(USER_DATA_KEY) ?? sessionStorage.getItem(USER_DATA_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch {
removeCookie(USER_DATA_KEY);
sessionStorage.removeItem(USER_DATA_KEY);
return null;
}
}
export function setUserData(userData) {
if (userData == null) {
removeCookie(USER_DATA_KEY);
sessionStorage.removeItem(USER_DATA_KEY);
return;
}
const serialized = JSON.stringify(userData);
setCookie(USER_DATA_KEY, serialized);
sessionStorage.setItem(USER_DATA_KEY, serialized);
}
export function setSession({ token, userData } = {}) {
if (token !== undefined) {
setToken(token);
}
if (userData !== undefined) {
setUserData(userData);
}
}
export function clearSession() {
removeCookie(TOKEN_KEY);
removeCookie(USER_DATA_KEY);
sessionStorage.removeItem(USER_DATA_KEY);
}