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
+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);
}