DONE: queue management for customer side to get queue number, add logo to teller display, advertisement module
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BBQMS Teller App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MyKOPKB QMS-Teller App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { Route, Routes, useLocation } from 'react-router-dom';
|
||||
import StationIntroPage from './pages/StationIntroPage/StationIntroPage.jsx';
|
||||
import ShowQueuesForTellerPage from './pages/ShowQueuesForTellerPage/ShowQueuesForTellerPage';
|
||||
import Header from './components/Header/Header.jsx';
|
||||
import CurrentTicketPage from './pages/CurrentTicketPage/CurrentTicketPage.jsx';
|
||||
import BranchDisplayPage from './pages/BranchDisplayPage/BranchDisplayPage.jsx';
|
||||
import LoginPage from './pages/LoginPage/LoginPage.jsx';
|
||||
import AuthGuard from './components/AuthGuard/AuthGuard.jsx';
|
||||
import { UserContext } from './context/UserContext.jsx';
|
||||
@@ -13,6 +14,13 @@ import { fetchData } from './fetching/Fetch.js';
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState(() => getUserData());
|
||||
const location = useLocation();
|
||||
const isDisplayRoute = location.pathname.startsWith('/display');
|
||||
|
||||
useEffect(() => {
|
||||
document.body.classList.toggle('display-mode', isDisplayRoute);
|
||||
return () => document.body.classList.remove('display-mode');
|
||||
}, [isDisplayRoute]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getToken();
|
||||
@@ -32,7 +40,7 @@ export default function App() {
|
||||
|
||||
return (
|
||||
<UserContext.Provider value={{ user, setUser }}>
|
||||
<Header />
|
||||
{isDisplayRoute ? null : <Header />}
|
||||
<Routes>
|
||||
<Route exact path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
@@ -53,7 +61,11 @@ export default function App() {
|
||||
</AuthGuard>
|
||||
}
|
||||
/>
|
||||
{/* Public display board for waiting area screens */}
|
||||
{/* Public display boards for waiting area screens */}
|
||||
<Route
|
||||
path="/display/branch/:tenantCode/:branchId"
|
||||
element={<BranchDisplayPage />}
|
||||
/>
|
||||
<Route path="/display/:stationId" element={<CurrentTicketPage />} />
|
||||
</Routes>
|
||||
</UserContext.Provider>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useContext } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { clearSession } from '../../utils/session.js';
|
||||
import { clearActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
export default function Header() {
|
||||
const navigate = useNavigate();
|
||||
@@ -10,6 +11,7 @@ export default function Header() {
|
||||
|
||||
function handleLogout() {
|
||||
clearSession();
|
||||
clearActiveStation();
|
||||
setUser(null);
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
@@ -17,7 +19,7 @@ export default function Header() {
|
||||
return (
|
||||
<header className="main-header">
|
||||
<h2 className="header-logo" onClick={() => navigate(user ? '/' : '/login')}>
|
||||
BBQMS Teller
|
||||
MyKOPKB QMS-Teller
|
||||
</h2>
|
||||
{user ? (
|
||||
<div className="header-user">
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
export const SERVER_URL = 'http://localhost:8080';
|
||||
|
||||
export const GOLD_PRICE_URL =
|
||||
import.meta.env.VITE_GOLD_PRICE_URL ??
|
||||
'https://apiujrah.erahn.com.my/api/harga_emas';
|
||||
|
||||
export const ROLES = {
|
||||
ROLE_USER: 'ROLE_USER',
|
||||
ROLE_SUPER_ADMIN: 'ROLE_SUPER_ADMIN',
|
||||
|
||||
@@ -14,6 +14,12 @@ body {
|
||||
background-color: ghostwhite;
|
||||
}
|
||||
|
||||
body.display-mode {
|
||||
padding: 0;
|
||||
background-color: #0f172a;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* ovdje definisite konstante boje i sl. koje cete koristiti na vise mjesta */
|
||||
--blue: #334257;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
|
||||
function resolveMediaUrl(mediaUrl) {
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.startsWith('http') ? mediaUrl : `${SERVER_URL}${mediaUrl}`;
|
||||
}
|
||||
|
||||
export default function AdCarousel({ tenantCode }) {
|
||||
const [ads, setAds] = useState([]);
|
||||
const [index, setIndex] = useState(0);
|
||||
const [error, setError] = useState(null);
|
||||
const timerRef = useRef(null);
|
||||
const videoRef = useRef(null);
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAds = useCallback(async () => {
|
||||
if (!tenantCode) return;
|
||||
try {
|
||||
const response = await fetchData(
|
||||
`${SERVER_URL}/api/v1/ads/${encodeURIComponent(tenantCode)}/active`,
|
||||
'GET'
|
||||
);
|
||||
if (!response.success) {
|
||||
setError('Could not load advertisements.');
|
||||
return;
|
||||
}
|
||||
const next = Array.isArray(response.data) ? response.data : [];
|
||||
setAds(next);
|
||||
setError(null);
|
||||
setIndex((current) => (next.length === 0 ? 0 : current % next.length));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Could not load advertisements.');
|
||||
}
|
||||
}, [tenantCode]);
|
||||
|
||||
useEffect(() => {
|
||||
loadAds();
|
||||
const interval = setInterval(loadAds, 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadAds]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setIndex((current) => {
|
||||
if (ads.length === 0) return 0;
|
||||
return (current + 1) % ads.length;
|
||||
});
|
||||
}, [ads.length]);
|
||||
|
||||
const currentAd = ads[index] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
clearTimer();
|
||||
if (!currentAd) return undefined;
|
||||
|
||||
if (currentAd.mediaType === 'IMAGE') {
|
||||
const durationMs = Math.max(1, Number(currentAd.durationSeconds) || 10) * 1000;
|
||||
timerRef.current = setTimeout(goNext, durationMs);
|
||||
return clearTimer;
|
||||
}
|
||||
|
||||
// Videos advance on ended; fallback if play fails
|
||||
timerRef.current = setTimeout(goNext, 5 * 60 * 1000);
|
||||
return clearTimer;
|
||||
}, [currentAd, goNext, clearTimer]);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
|
||||
|
||||
const onEnded = () => goNext();
|
||||
video.addEventListener('ended', onEnded);
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
const playPromise = video.play();
|
||||
if (playPromise?.catch) {
|
||||
playPromise.catch(() => {
|
||||
// Autoplay blocked — advance after durationSeconds fallback
|
||||
clearTimer();
|
||||
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
|
||||
timerRef.current = setTimeout(goNext, durationMs);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, [currentAd, goNext, clearTimer]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
<p className="branch-display__empty">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (ads.length === 0) {
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
<p className="branch-display__empty">Tiada pengiklanan aktif.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const src = resolveMediaUrl(currentAd.mediaUrl);
|
||||
|
||||
return (
|
||||
<section className="branch-display__ad-panel">
|
||||
<div className="branch-display__ad-header">
|
||||
<h2 className="branch-display__section-title">Pengiklanan</h2>
|
||||
</div>
|
||||
<div className="branch-display__ad-stage">
|
||||
{currentAd.mediaType === 'VIDEO' ? (
|
||||
<video
|
||||
key={currentAd.id}
|
||||
ref={videoRef}
|
||||
className="branch-display__ad-media"
|
||||
src={src}
|
||||
muted
|
||||
playsInline
|
||||
autoPlay
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
key={currentAd.id}
|
||||
className="branch-display__ad-media"
|
||||
src={src}
|
||||
alt={currentAd.title || currentAd.fileName || 'Advertisement'}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{currentAd.title ? (
|
||||
<p className="branch-display__ad-caption">{currentAd.title}</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
.branch-display {
|
||||
--bd-bg: #071a10;
|
||||
--bd-bg-soft: #0c2416;
|
||||
--bd-surface: #12301f;
|
||||
--bd-surface-raised: #183c28;
|
||||
--bd-border: #245537;
|
||||
--bd-border-strong: #2f6b45;
|
||||
--bd-text: #f3faf5;
|
||||
--bd-muted: #9bc4a8;
|
||||
--bd-muted-strong: #c5e0cf;
|
||||
--bd-accent: #f0d060;
|
||||
--bd-accent-bright: #ffe566;
|
||||
--bd-accent-deep: #d4a017;
|
||||
--bd-green: #3ecf7a;
|
||||
--bd-error-bg: #5c1a1a;
|
||||
--bd-error-text: #fecaca;
|
||||
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 1rem 1.5rem 1rem;
|
||||
background:
|
||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%),
|
||||
radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
|
||||
linear-gradient(165deg, var(--bd-bg-soft) 0%, var(--bd-bg) 55%, #05140c 100%);
|
||||
color: var(--bd-text);
|
||||
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__sound-enable {
|
||||
position: absolute;
|
||||
top: 0.75rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
border: 1px solid var(--bd-accent-deep);
|
||||
background: linear-gradient(180deg, var(--bd-accent-bright) 0%, var(--bd-accent) 100%);
|
||||
color: #1a2e14;
|
||||
border-radius: 999px;
|
||||
padding: 0.45rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 12px rgba(240, 208, 96, 0.35);
|
||||
}
|
||||
|
||||
.branch-display__header {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.branch-display__brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.85rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.branch-display__logo {
|
||||
height: clamp(2.5rem, 4.5vw, 3.5rem);
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
border-radius: 0.35rem;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.branch-display__eyebrow {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.4rem, 2.2vw, 2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bd-text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.branch-display__error {
|
||||
margin: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--bd-error-bg);
|
||||
color: var(--bd-error-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr);
|
||||
gap: 0.85rem;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.branch-display {
|
||||
height: auto;
|
||||
min-height: 100vh;
|
||||
overflow: auto;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
.branch-display__main {
|
||||
grid-template-columns: 1fr;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.branch-display__section--waiting {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.branch-display__section--gold {
|
||||
flex: none;
|
||||
min-height: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
.branch-display__queue-column {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__section {
|
||||
margin: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section--waiting {
|
||||
flex: 0 1 auto;
|
||||
max-height: 28%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section--gold {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.branch-display__section-title {
|
||||
margin: 0 0 0.45rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-muted-strong);
|
||||
}
|
||||
|
||||
.branch-display__stations {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.branch-display__station-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
padding: 0.65rem 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
||||
border: 1px solid var(--bd-border);
|
||||
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.branch-display__station-name {
|
||||
margin: 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bd-muted);
|
||||
}
|
||||
|
||||
.branch-display__ticket-number {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 3.2vw, 2.75rem);
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
letter-spacing: -0.03em;
|
||||
color: var(--bd-accent-bright);
|
||||
text-shadow: 0 0 24px rgba(255, 229, 102, 0.25);
|
||||
}
|
||||
|
||||
.branch-display__ticket-number--idle {
|
||||
color: #4a7a5c;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.branch-display__station-service {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--bd-muted-strong);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branch-display__waiting-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-header .branch-display__section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-count {
|
||||
margin: 0;
|
||||
font-size: 0.85rem;
|
||||
color: var(--bd-accent);
|
||||
}
|
||||
|
||||
.branch-display__empty {
|
||||
margin: 0;
|
||||
color: var(--bd-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__waiting-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.branch-display__waiting-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
padding: 0.4rem 0.7rem;
|
||||
border-radius: 0.4rem;
|
||||
background: var(--bd-surface);
|
||||
border: 1px solid var(--bd-border);
|
||||
border-left: 3px solid var(--bd-green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__waiting-number {
|
||||
font-size: clamp(1.1rem, 1.8vw, 1.5rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--bd-accent);
|
||||
}
|
||||
|
||||
.branch-display__waiting-service {
|
||||
font-size: 0.85rem;
|
||||
color: var(--bd-muted-strong);
|
||||
}
|
||||
|
||||
.branch-display__ad-panel {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
padding: 0.65rem;
|
||||
border-radius: 0.6rem;
|
||||
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
|
||||
border: 1px solid var(--bd-border);
|
||||
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.branch-display__ad-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.branch-display__ad-header .branch-display__section-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.branch-display__ad-stage {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
border-radius: 0.4rem;
|
||||
overflow: hidden;
|
||||
background: var(--bd-bg);
|
||||
border: 1px solid var(--bd-border);
|
||||
}
|
||||
|
||||
.branch-display__ad-media {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
background: var(--bd-bg);
|
||||
}
|
||||
|
||||
.branch-display__ad-caption {
|
||||
margin: 0.35rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--bd-muted-strong);
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.branch-display__section--gold .branch-display__waiting-header {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.branch-display__gold-table-wrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--bd-border);
|
||||
background: var(--bd-surface);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table-wrap::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.branch-display__gold-table th,
|
||||
.branch-display__gold-table td {
|
||||
padding: 0.45rem 0.65rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--bd-border);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.branch-display__gold-table th {
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bd-accent);
|
||||
font-weight: 600;
|
||||
background: var(--bd-bg-soft);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.branch-display__gold-table td:nth-child(3) {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
color: var(--bd-accent-bright);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.branch-display__gold-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.branch-display__gold-table tbody tr:hover td {
|
||||
background: rgba(62, 207, 122, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js';
|
||||
import {
|
||||
hasNewServingCall,
|
||||
playQueueCallSound,
|
||||
servingSnapshot,
|
||||
unlockQueueCallSound,
|
||||
} from '../../utils/queueCallSound.js';
|
||||
import AdCarousel from './AdCarousel.jsx';
|
||||
import './BranchDisplayPage.css';
|
||||
|
||||
function formatGoldPrice(value) {
|
||||
const amount = Number(value);
|
||||
if (!Number.isFinite(amount)) return value ?? '—';
|
||||
return amount.toLocaleString('en-MY', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
export default function BranchDisplayPage() {
|
||||
const { tenantCode, branchId } = useParams();
|
||||
const [branchName, setBranchName] = useState('');
|
||||
const [stations, setStations] = useState([]);
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [goldPrices, setGoldPrices] = useState([]);
|
||||
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
|
||||
const [goldError, setGoldError] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [soundReady, setSoundReady] = useState(false);
|
||||
const goldTableWrapRef = useRef(null);
|
||||
const previousServingRef = useRef(null);
|
||||
|
||||
const loadDisplay = useCallback(async () => {
|
||||
if (!tenantCode || !branchId) return;
|
||||
|
||||
try {
|
||||
const [stationsRes, queueRes, branchesRes] = await Promise.all([
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/stations/${encodeURIComponent(tenantCode)}/${branchId}`,
|
||||
'GET'
|
||||
),
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/branches/${encodeURIComponent(tenantCode)}/${branchId}/queue?activeOnly=true&sort=createdAt,asc`,
|
||||
'GET'
|
||||
),
|
||||
fetchData(
|
||||
`${SERVER_URL}/api/v1/branches/${encodeURIComponent(tenantCode)}`,
|
||||
'GET'
|
||||
),
|
||||
]);
|
||||
|
||||
if (!stationsRes.success || !queueRes.success) {
|
||||
setError('Could not load branch display.');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setStations(Array.isArray(stationsRes.data) ? stationsRes.data : []);
|
||||
setTickets(Array.isArray(queueRes.data) ? queueRes.data : []);
|
||||
|
||||
if (branchesRes.success && Array.isArray(branchesRes.data)) {
|
||||
const branch = branchesRes.data.find(
|
||||
(item) => Number(item.id) === Number(branchId)
|
||||
);
|
||||
if (branch?.name) {
|
||||
setBranchName(branch.name);
|
||||
}
|
||||
} else if (queueRes.data?.[0]?.branch?.name) {
|
||||
setBranchName(queueRes.data[0].branch.name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError('Could not load branch display.');
|
||||
}
|
||||
}, [tenantCode, branchId]);
|
||||
|
||||
const loadGoldPrices = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(GOLD_PRICE_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Gold price request failed (${response.status})`);
|
||||
}
|
||||
const data = await response.json();
|
||||
const rows = Array.isArray(data) ? data : [];
|
||||
setGoldPrices(rows);
|
||||
setGoldUpdatedAt(rows[0]?.tarikhupdate ?? null);
|
||||
setGoldError(null);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setGoldError('Could not load gold prices.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadDisplay();
|
||||
const interval = setInterval(loadDisplay, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadDisplay]);
|
||||
|
||||
useEffect(() => {
|
||||
loadGoldPrices();
|
||||
const interval = setInterval(loadGoldPrices, 5 * 60 * 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadGoldPrices]);
|
||||
|
||||
useEffect(() => {
|
||||
const wrap = goldTableWrapRef.current;
|
||||
if (!wrap || goldPrices.length === 0) return undefined;
|
||||
|
||||
let rafId = 0;
|
||||
let pauseUntil = 0;
|
||||
let direction = 1;
|
||||
const speedPxPerSec = 28;
|
||||
const pauseMs = 1600;
|
||||
let lastTs = 0;
|
||||
|
||||
const step = (now) => {
|
||||
if (!lastTs) lastTs = now;
|
||||
const dt = Math.min(now - lastTs, 50);
|
||||
lastTs = now;
|
||||
|
||||
const maxScroll = wrap.scrollHeight - wrap.clientHeight;
|
||||
if (maxScroll <= 2) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
if (now < pauseUntil) {
|
||||
rafId = requestAnimationFrame(step);
|
||||
return;
|
||||
}
|
||||
|
||||
wrap.scrollTop += (speedPxPerSec * dt * direction) / 1000;
|
||||
|
||||
if (direction > 0 && wrap.scrollTop >= maxScroll - 1) {
|
||||
wrap.scrollTop = maxScroll;
|
||||
direction = -1;
|
||||
pauseUntil = now + pauseMs;
|
||||
} else if (direction < 0 && wrap.scrollTop <= 1) {
|
||||
wrap.scrollTop = 0;
|
||||
direction = 1;
|
||||
pauseUntil = now + pauseMs;
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
const startId = requestAnimationFrame(() => {
|
||||
wrap.scrollTop = 0;
|
||||
lastTs = 0;
|
||||
rafId = requestAnimationFrame(step);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(startId);
|
||||
cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [goldPrices]);
|
||||
|
||||
const servingByStationId = useMemo(() => {
|
||||
const map = new Map();
|
||||
for (const ticket of tickets) {
|
||||
if (ticket.station?.id != null) {
|
||||
map.set(Number(ticket.station.id), ticket);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [tickets]);
|
||||
|
||||
useEffect(() => {
|
||||
const next = servingSnapshot(tickets);
|
||||
const previous = previousServingRef.current;
|
||||
|
||||
if (hasNewServingCall(previous, next)) {
|
||||
playQueueCallSound();
|
||||
}
|
||||
|
||||
previousServingRef.current = next;
|
||||
}, [tickets]);
|
||||
|
||||
const waiting = useMemo(
|
||||
() => tickets.filter((ticket) => ticket.station == null),
|
||||
[tickets]
|
||||
);
|
||||
|
||||
const sortedStations = useMemo(
|
||||
() => [...stations].sort((a, b) => String(a.name).localeCompare(String(b.name))),
|
||||
[stations]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="branch-display"
|
||||
onClick={() => {
|
||||
if (!soundReady) {
|
||||
unlockQueueCallSound();
|
||||
setSoundReady(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!soundReady ? (
|
||||
<button
|
||||
type="button"
|
||||
className="branch-display__sound-enable"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
unlockQueueCallSound();
|
||||
setSoundReady(true);
|
||||
}}
|
||||
>
|
||||
Enable call sound
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<header className="branch-display__header">
|
||||
<div className="branch-display__brand">
|
||||
<img
|
||||
className="branch-display__logo"
|
||||
src="/logo-arrahn.jpeg"
|
||||
alt="ar-rahn"
|
||||
/>
|
||||
<h1 className="branch-display__title">
|
||||
Selamat Datang ke Cawangan {branchName}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="branch-display__eyebrow">Sistem Nombor Giliran</p>
|
||||
</header>
|
||||
|
||||
{error ? <p className="branch-display__error">{error}</p> : null}
|
||||
|
||||
<div className="branch-display__main">
|
||||
<div className="branch-display__queue-column">
|
||||
<section className="branch-display__section">
|
||||
<h2 className="branch-display__section-title">Sedang Diproses</h2>
|
||||
{sortedStations.length === 0 ? (
|
||||
<p className="branch-display__empty">Tiada Stesen di Branch ini.</p>
|
||||
) : (
|
||||
<div className="branch-display__stations">
|
||||
{sortedStations.map((station) => {
|
||||
const ticket = servingByStationId.get(Number(station.id));
|
||||
return (
|
||||
<article
|
||||
key={station.id}
|
||||
className="branch-display__station-card"
|
||||
>
|
||||
<p className="branch-display__station-name">
|
||||
{station.name}
|
||||
</p>
|
||||
<p
|
||||
className={
|
||||
ticket
|
||||
? 'branch-display__ticket-number'
|
||||
: 'branch-display__ticket-number branch-display__ticket-number--idle'
|
||||
}
|
||||
>
|
||||
{ticket ? ticket.number : '—'}
|
||||
</p>
|
||||
<p className="branch-display__station-service">
|
||||
{ticket?.service?.name ?? 'Waiting for next'}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="branch-display__section branch-display__section--waiting">
|
||||
<div className="branch-display__waiting-header">
|
||||
<h2 className="branch-display__section-title">Giliran</h2>
|
||||
<p className="branch-display__waiting-count">
|
||||
{waiting.length} dalam giliran
|
||||
</p>
|
||||
</div>
|
||||
{waiting.length === 0 ? (
|
||||
<p className="branch-display__empty">Tiada nombor dalam giliran.</p>
|
||||
) : (
|
||||
<ul className="branch-display__waiting-list">
|
||||
{waiting.map((ticket) => (
|
||||
<li key={ticket.id} className="branch-display__waiting-item">
|
||||
<span className="branch-display__waiting-number">
|
||||
{ticket.number}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="branch-display__section branch-display__section--gold">
|
||||
<div className="branch-display__waiting-header">
|
||||
<h2 className="branch-display__section-title">Harga Emas</h2>
|
||||
{goldUpdatedAt ? (
|
||||
<p className="branch-display__waiting-count">
|
||||
Dikemas kini pada {goldUpdatedAt}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{goldError ? (
|
||||
<p className="branch-display__error">{goldError}</p>
|
||||
) : null}
|
||||
{!goldError && goldPrices.length === 0 ? (
|
||||
<p className="branch-display__empty">Loading harga emas…</p>
|
||||
) : null}
|
||||
{goldPrices.length > 0 ? (
|
||||
<div
|
||||
ref={goldTableWrapRef}
|
||||
className="branch-display__gold-table-wrap"
|
||||
>
|
||||
<table className="branch-display__gold-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Karat</th>
|
||||
<th>Mutu</th>
|
||||
<th>RM/g</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{goldPrices.map((row) => (
|
||||
<tr
|
||||
key={
|
||||
row.recno ??
|
||||
`${row.karat}-${row.keterangan}`
|
||||
}
|
||||
>
|
||||
<td>{row.karat}</td>
|
||||
<td>{row.keterangan}</td>
|
||||
<td>{formatGoldPrice(row.harga)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<AdCarousel tenantCode={tenantCode} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +1,203 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useContext, useEffect, useState } from 'react';
|
||||
import { Button, Table } from 'react-bootstrap';
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { useInterval } from '../../hooks/hooks.jsx';
|
||||
import { fetchData } from '../../fetching/Fetch.js'
|
||||
import { fetchData } from '../../fetching/Fetch.js';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { getActiveStation, saveActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
const styles = {
|
||||
stationRow: {
|
||||
backgroundColor: "#ADD8E6",
|
||||
}
|
||||
backgroundColor: '#ADD8E6',
|
||||
},
|
||||
};
|
||||
|
||||
export default function ShowQueuesForTellerPage() {
|
||||
const url = `${ SERVER_URL }/api/v1/`;
|
||||
const [ tickets, setTickets ] = useState([]);
|
||||
const [ usedUndo, setUsedUndo ] = useState(false)
|
||||
const [ timedOut, setTimedOut ] = useState(false)
|
||||
const url = `${SERVER_URL}/api/v1/`;
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [usedUndo, setUsedUndo] = useState(false);
|
||||
const [timedOut, setTimedOut] = useState(false);
|
||||
const [stationLabel, setStationLabel] = useState(null);
|
||||
|
||||
const { stationId } = useParams();
|
||||
const location = useLocation();
|
||||
const { user } = useContext(UserContext);
|
||||
const tenantCode = user?.tenantCode;
|
||||
|
||||
const { stationId} = useParams()
|
||||
|
||||
useEffect(() => {
|
||||
fetchTicketsForTellerStation();
|
||||
}, [ stationId ]);
|
||||
}, [stationId]);
|
||||
|
||||
useInterval(fetchTicketsForTellerStation, 10000)
|
||||
useEffect(() => {
|
||||
const fromNav = location.state;
|
||||
if (fromNav?.station?.name) {
|
||||
const next = {
|
||||
stationId: Number(stationId),
|
||||
stationName: fromNav.station.name,
|
||||
branchId: fromNav.branch?.id ?? null,
|
||||
branchName: fromNav.branch?.name ?? null,
|
||||
};
|
||||
saveActiveStation(next);
|
||||
setStationLabel(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromSession = getActiveStation(stationId);
|
||||
if (fromSession?.stationName) {
|
||||
setStationLabel(fromSession);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tenantCode) {
|
||||
setStationLabel({ stationId: Number(stationId), stationName: `Station ${stationId}` });
|
||||
return;
|
||||
}
|
||||
|
||||
fetchData(`${url}branches/${encodeURIComponent(tenantCode)}`, 'GET')
|
||||
.then(({ data, success }) => {
|
||||
if (!success || !Array.isArray(data)) {
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
for (const branch of data) {
|
||||
const station = branch.tellerStations?.find(
|
||||
(item) => Number(item.id) === Number(stationId)
|
||||
);
|
||||
if (station) {
|
||||
const next = {
|
||||
stationId: Number(stationId),
|
||||
stationName: station.name,
|
||||
branchId: branch.id,
|
||||
branchName: branch.name,
|
||||
};
|
||||
saveActiveStation(next);
|
||||
setStationLabel(next);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
setStationLabel({
|
||||
stationId: Number(stationId),
|
||||
stationName: `Station ${stationId}`,
|
||||
});
|
||||
});
|
||||
}, [stationId, tenantCode, location.state, url]);
|
||||
|
||||
useInterval(fetchTicketsForTellerStation, 10000);
|
||||
|
||||
function fetchTicketsForTellerStation() {
|
||||
fetchData(`${ url }stations/${ stationId }/tickets`, 'GET')
|
||||
.then(response => response.data)
|
||||
fetchData(`${url}stations/${stationId}/tickets`, 'GET')
|
||||
.then((response) => response.data)
|
||||
.then(setTickets)
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function advanceQueue() {
|
||||
fetchData(`${ url }teller/advance-queue/${ stationId }`, 'POST')
|
||||
fetchData(`${url}teller/advance-queue/${stationId}`, 'POST')
|
||||
.then(fetchTicketsForTellerStation)
|
||||
.then(() => setUsedUndo(false))
|
||||
.then(timeOutTeller)
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function undoQueue() {
|
||||
fetchData(`${ url }teller/undo-queue/${ stationId }`, 'POST')
|
||||
fetchData(`${url}teller/undo-queue/${stationId}`, 'POST')
|
||||
.then(fetchTicketsForTellerStation)
|
||||
.then(() => setUsedUndo(true))
|
||||
.catch(console.error)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function timeOutTeller() {
|
||||
setTimedOut(true)
|
||||
setTimeout(() => setTimedOut(false), 5000)
|
||||
setTimedOut(true);
|
||||
setTimeout(() => setTimedOut(false), 5000);
|
||||
// Can't advance more often than every 5 seconds.
|
||||
}
|
||||
|
||||
const hasAssignedTicket = tickets.some(ticket => ticket.station?.id == stationId)
|
||||
const hasAssignedTicket = tickets.some((ticket) => ticket.station?.id == stationId);
|
||||
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<h1>Queues</h1>
|
||||
<div className="d-flex gap-2 justify-content-center">
|
||||
{stationLabel ? (
|
||||
<p className="mb-3" style={{ fontSize: '1.25rem', color: '#334257' }}>
|
||||
<strong>{stationLabel.stationName}</strong>
|
||||
{stationLabel.branchName ? (
|
||||
<span className="text-muted"> · {stationLabel.branchName}</span>
|
||||
) : null}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="d-flex gap-2 justify-content-center flex-wrap">
|
||||
<Button
|
||||
variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ advanceQueue }
|
||||
disabled={ timedOut }
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={advanceQueue}
|
||||
disabled={timedOut}
|
||||
>
|
||||
Advance Queue
|
||||
Nombor Giliran Seterusnya
|
||||
</Button>
|
||||
<Button variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ undoQueue }
|
||||
disabled={ usedUndo || !hasAssignedTicket }>
|
||||
Undo
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={undoQueue}
|
||||
disabled={usedUndo || !hasAssignedTicket}
|
||||
>
|
||||
Patah balik
|
||||
</Button>
|
||||
<Button variant="primary"
|
||||
style={ { backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' } }
|
||||
onClick={ fetchTicketsForTellerStation }>
|
||||
Refresh
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{ backgroundColor: '#548CA8', color: 'white', borderColor: '#548CA8' }}
|
||||
onClick={fetchTicketsForTellerStation}
|
||||
>
|
||||
Muat semula
|
||||
</Button>
|
||||
<Link to={`/display/${stationId}`} target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
>
|
||||
Current Ticket
|
||||
</Button>
|
||||
<Button variant="secondary">Tiket semasa</Button>
|
||||
</Link>
|
||||
{tenantCode && stationLabel?.branchId ? (
|
||||
<Link
|
||||
to={`/display/branch/${encodeURIComponent(tenantCode)}/${stationLabel.branchId}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="secondary">Paparan TV</Button>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={ { marginTop: '20px' } }>
|
||||
<div style={{ marginTop: '20px' }}>
|
||||
<Table striped bordered hover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Ticket number</th>
|
||||
<th>Service</th>
|
||||
<th>Creation at</th>
|
||||
<th>Nombor tiket</th>
|
||||
<th>Perkhidmatan</th>
|
||||
<th>Dibuat pada</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tickets.map((ticket, index) => {
|
||||
{tickets.map((ticket, index) => {
|
||||
const createdAt = new Date(ticket.createdAt);
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td style={ticket.station?.id == stationId ? styles.stationRow : {}}>{ticket.number}</td>
|
||||
<td
|
||||
style={
|
||||
ticket.station?.id == stationId
|
||||
? styles.stationRow
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{ticket.number}
|
||||
</td>
|
||||
<td>{ticket.service.name}</td>
|
||||
<td>{createdAt.toLocaleString()}</td>
|
||||
</tr>
|
||||
@@ -111,5 +207,5 @@ export default function ShowQueuesForTellerPage() {
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import { SERVER_URL } from '../../constants.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { UserContext } from '../../context/UserContext.jsx';
|
||||
import { saveActiveStation } from '../../utils/activeStation.js';
|
||||
|
||||
const StationIntroPage = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -77,15 +78,26 @@ const StationIntroPage = () => {
|
||||
|
||||
function handleStationSelect(station) {
|
||||
setSelectedStation(station);
|
||||
navigate(`/teller-queue/${station.id}`);
|
||||
saveActiveStation({
|
||||
stationId: station.id,
|
||||
stationName: station.name,
|
||||
branchId: selectedBranch?.id,
|
||||
branchName: selectedBranch?.name,
|
||||
});
|
||||
navigate(`/teller-queue/${station.id}`, {
|
||||
state: {
|
||||
station,
|
||||
branch: selectedBranch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center mt-5">
|
||||
<div className="border p-3" style={{ maxWidth: '500px', margin: '0 auto' }}>
|
||||
<h1 className="mb-2">Select Branch and Station</h1>
|
||||
<h1 className="mb-2">Pilih Cawangan dan Kaunter</h1>
|
||||
{tenantCode ? (
|
||||
<p className="text-muted mb-4">Tenant: {tenantCode}</p>
|
||||
<p className="text-muted mb-4">Syarikat: {tenantCode}</p>
|
||||
) : null}
|
||||
|
||||
{error ? <p className="text-danger">{error}</p> : null}
|
||||
@@ -98,10 +110,10 @@ const StationIntroPage = () => {
|
||||
disabled={loadingBranches || branches.length === 0}
|
||||
>
|
||||
{loadingBranches
|
||||
? 'Loading branches…'
|
||||
? 'Loading cawangan…'
|
||||
: selectedBranch
|
||||
? selectedBranch.name
|
||||
: 'Select Branch'}
|
||||
? selectedBranch.name
|
||||
: 'Pilih Cawangan'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu style={{ width: '100%' }}>
|
||||
{branches.map((branch) => (
|
||||
@@ -125,10 +137,10 @@ const StationIntroPage = () => {
|
||||
disabled={loadingStations || stations.length === 0}
|
||||
>
|
||||
{loadingStations
|
||||
? 'Loading stations…'
|
||||
? 'Loading kaunter…'
|
||||
: selectedStation
|
||||
? selectedStation.name
|
||||
: 'Select Station'}
|
||||
? selectedStation.name
|
||||
: 'Pilih Kaunter'}
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu style={{ width: '100%' }}>
|
||||
{stations.map((station) => (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const ACTIVE_STATION_KEY = 'qms_teller_active_station';
|
||||
|
||||
export function saveActiveStation({ stationId, stationName, branchId, branchName }) {
|
||||
sessionStorage.setItem(
|
||||
ACTIVE_STATION_KEY,
|
||||
JSON.stringify({
|
||||
stationId: Number(stationId),
|
||||
stationName,
|
||||
branchId: branchId != null ? Number(branchId) : null,
|
||||
branchName: branchName ?? null,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function getActiveStation(stationId) {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(ACTIVE_STATION_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (stationId != null && Number(parsed.stationId) !== Number(stationId)) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearActiveStation() {
|
||||
sessionStorage.removeItem(ACTIVE_STATION_KEY);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
const CALL_SOUND_URL = '/sounds/header-sound.mp3';
|
||||
|
||||
let sharedAudio = null;
|
||||
let unlocked = false;
|
||||
|
||||
function getCallAudio() {
|
||||
if (!sharedAudio) {
|
||||
sharedAudio = new Audio(CALL_SOUND_URL);
|
||||
sharedAudio.preload = 'auto';
|
||||
}
|
||||
return sharedAudio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call once after a user gesture (click/tap) so later plays are allowed.
|
||||
*/
|
||||
export function unlockQueueCallSound() {
|
||||
if (unlocked) return;
|
||||
try {
|
||||
const audio = getCallAudio();
|
||||
audio.muted = true;
|
||||
const playPromise = audio.play();
|
||||
if (playPromise?.then) {
|
||||
playPromise
|
||||
.then(() => {
|
||||
audio.pause();
|
||||
audio.currentTime = 0;
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
})
|
||||
.catch(() => {
|
||||
// Still mark unlocked so we keep trying on real calls.
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
});
|
||||
} else {
|
||||
audio.muted = false;
|
||||
unlocked = true;
|
||||
}
|
||||
} catch {
|
||||
unlocked = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play the queue-call notification chime.
|
||||
* Safe to call repeatedly; restarts from the beginning each time.
|
||||
*/
|
||||
export function playQueueCallSound() {
|
||||
try {
|
||||
const audio = getCallAudio();
|
||||
audio.muted = false;
|
||||
audio.currentTime = 0;
|
||||
const playPromise = audio.play();
|
||||
if (playPromise?.catch) {
|
||||
playPromise.catch((error) => {
|
||||
console.warn('Could not play queue call sound:', error);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Could not play queue call sound:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stable map of stationId -> ticket number for currently serving tickets.
|
||||
*/
|
||||
export function servingSnapshot(tickets) {
|
||||
const map = {};
|
||||
for (const ticket of tickets) {
|
||||
if (ticket?.station?.id != null && ticket?.number != null) {
|
||||
map[String(ticket.station.id)] = String(ticket.number);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any station got a new/different ticket number vs the previous snapshot.
|
||||
*/
|
||||
export function hasNewServingCall(previous, next) {
|
||||
if (!previous) return false;
|
||||
for (const [stationId, number] of Object.entries(next)) {
|
||||
if (previous[stationId] !== number) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||