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

This commit is contained in:
ISMAIL MASSERAN
2026-07-23 10:50:35 +08:00
parent a671f7ad5c
commit 9bc06f1d5b
72 changed files with 13919 additions and 182 deletions
+15
View File
@@ -6,15 +6,30 @@ This project aims to develop a comprehensive queue management system for bank br
### Teller Interface
The Teller Interface provides functionality for tellers to manage their availability status, allowing them to switch between available and non-available states. This interface will be developed using React.
```bash
pnpm dev --port 3001
```
### Admin Interface
The Admin Interface is designed for administrators to oversee and manage both queues and tellers efficiently. Admins will have the ability to monitor queue statuses, assign tasks to tellers, and make necessary adjustments as needed. This interface will also be developed using React.
```bash
pnpm dev --port 5000
```
### Customer Interface
The Teller Interface provides functionality for tellers to manage their availability status, allowing them to switch between available and non-available states. This interface will be developed using React.
```bash
pnpm dev --port 3000
```
### Mobile App Interface
The Mobile App Interface is catered towards customers visiting the bank branch. Customers can utilize the mobile app to generate a ticket for the queue, allowing them to efficiently manage their time while waiting for service. This interface will be developed using React Native, ensuring compatibility across both iOS and Android devices.
## Backend
The backend of the queue management system will be developed using Java's Spring Boot framework. It will serve as the central component handling communication between the interfaces and managing the underlying data and business logic. The backend will be responsible for tasks such as processing queue requests, managing teller availability, and maintaining queue status updates.
```bash
./mvnw spring-boot:run
```
## Setup Instructions
1. Clone the repository to your local machine.
+3
View File
@@ -31,3 +31,6 @@ build/
### VS Code ###
.vscode/
### Uploads ###
uploads/
+4 -4
View File
@@ -1,14 +1,14 @@
services:
mysql:
image: mysql:8
container_name: bbqms-mysql
container_name: qms-mysql
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: bbqms
MYSQL_DATABASE: qms
volumes:
- bbqms-mysql-data:/var/lib/mysql
- qms-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-ppassword"]
interval: 5s
@@ -16,4 +16,4 @@ services:
retries: 10
volumes:
bbqms-mysql-data:
qms-mysql-data:
@@ -0,0 +1,35 @@
package ba.unsa.etf.si.bbqms.admin_service.api;
import ba.unsa.etf.si.bbqms.domain.Advertisement;
import org.springframework.core.io.Resource;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
public interface AdvertisementService {
Advertisement create(String tenantCode,
MultipartFile file,
String title,
Integer durationSeconds,
Integer sortOrder,
Boolean active) throws Exception;
List<Advertisement> listByTenant(String tenantCode);
List<Advertisement> listActiveByTenant(String tenantCode);
Advertisement update(String tenantCode,
long adId,
String title,
Integer durationSeconds,
Integer sortOrder,
Boolean active) throws Exception;
void delete(String tenantCode, long adId) throws Exception;
Advertisement getForTenant(String tenantCode, long adId);
Advertisement findById(long adId);
Resource loadMedia(long adId) throws Exception;
}
@@ -0,0 +1,214 @@
package ba.unsa.etf.si.bbqms.admin_service.implementation;
import ba.unsa.etf.si.bbqms.admin_service.api.AdvertisementService;
import ba.unsa.etf.si.bbqms.domain.Advertisement;
import ba.unsa.etf.si.bbqms.domain.AdvertisementMediaType;
import ba.unsa.etf.si.bbqms.domain.Tenant;
import ba.unsa.etf.si.bbqms.repository.AdvertisementRepository;
import ba.unsa.etf.si.bbqms.tenant_service.api.TenantService;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Instant;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
@Service
public class DefaultAdvertisementService implements AdvertisementService {
private static final int DEFAULT_DURATION_SECONDS = 10;
private static final Set<String> IMAGE_TYPES = Set.of(
"image/jpeg",
"image/png",
"image/webp",
"image/gif"
);
private static final Set<String> VIDEO_TYPES = Set.of(
"video/mp4",
"video/webm"
);
private final AdvertisementRepository advertisementRepository;
private final TenantService tenantService;
private final Path uploadRoot;
public DefaultAdvertisementService(final AdvertisementRepository advertisementRepository,
final TenantService tenantService,
@Value("${ads.upload-dir:uploads/ads}") final String uploadDir) {
this.advertisementRepository = advertisementRepository;
this.tenantService = tenantService;
this.uploadRoot = Path.of(uploadDir).toAbsolutePath().normalize();
}
@Override
public Advertisement create(final String tenantCode,
final MultipartFile file,
final String title,
final Integer durationSeconds,
final Integer sortOrder,
final Boolean active) throws Exception {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("File is required.");
}
final String contentType = normalizeContentType(file.getContentType());
final AdvertisementMediaType mediaType = resolveMediaType(contentType);
final Tenant tenant = this.tenantService.findByCode(tenantCode);
final String originalName = file.getOriginalFilename() != null ? file.getOriginalFilename() : "upload";
final String safeFileName = sanitizeFileName(originalName);
final String storedName = UUID.randomUUID() + "-" + safeFileName;
final Path tenantDir = this.uploadRoot.resolve(tenantCode).normalize();
Files.createDirectories(tenantDir);
final Path destination = tenantDir.resolve(storedName).normalize();
if (!destination.startsWith(tenantDir)) {
throw new IllegalArgumentException("Invalid file path.");
}
try {
Files.copy(file.getInputStream(), destination, StandardCopyOption.REPLACE_EXISTING);
} catch (final IOException exception) {
throw new IllegalStateException("Failed to store advertisement file.", exception);
}
final String relativePath = this.uploadRoot.relativize(destination).toString().replace('\\', '/');
final int nextSortOrder = sortOrder != null
? sortOrder
: this.advertisementRepository.findByTenant_CodeOrderBySortOrderAscIdAsc(tenantCode).stream()
.mapToInt(Advertisement::getSortOrder)
.max()
.orElse(-1) + 1;
final Advertisement advertisement = new Advertisement(
tenant,
title,
mediaType,
originalName,
contentType,
relativePath,
nextSortOrder,
active == null || active,
durationSeconds != null ? durationSeconds : DEFAULT_DURATION_SECONDS,
Instant.now()
);
return this.advertisementRepository.save(advertisement);
}
@Override
public List<Advertisement> listByTenant(final String tenantCode) {
this.tenantService.findByCode(tenantCode);
return this.advertisementRepository.findByTenant_CodeOrderBySortOrderAscIdAsc(tenantCode);
}
@Override
public List<Advertisement> listActiveByTenant(final String tenantCode) {
this.tenantService.findByCode(tenantCode);
return this.advertisementRepository.findByTenant_CodeAndActiveTrueOrderBySortOrderAscIdAsc(tenantCode);
}
@Override
public Advertisement update(final String tenantCode,
final long adId,
final String title,
final Integer durationSeconds,
final Integer sortOrder,
final Boolean active) {
final Advertisement advertisement = getForTenant(tenantCode, adId);
if (title != null) {
advertisement.setTitle(title);
}
if (durationSeconds != null) {
advertisement.setDurationSeconds(durationSeconds);
}
if (sortOrder != null) {
advertisement.setSortOrder(sortOrder);
}
if (active != null) {
advertisement.setActive(active);
}
return this.advertisementRepository.save(advertisement);
}
@Override
public void delete(final String tenantCode, final long adId) throws Exception {
final Advertisement advertisement = getForTenant(tenantCode, adId);
final Path filePath = resolveStoredPath(advertisement.getStoragePath());
this.advertisementRepository.delete(advertisement);
try {
Files.deleteIfExists(filePath);
} catch (final IOException exception) {
// Row is already removed; log-worthy but don't fail the API for orphan files.
}
}
@Override
public Advertisement getForTenant(final String tenantCode, final long adId) {
final Advertisement advertisement = this.advertisementRepository.get(adId);
if (!advertisement.getTenant().getCode().equals(tenantCode)) {
throw new EntityNotFoundException("Advertisement not found for tenant: " + tenantCode);
}
return advertisement;
}
@Override
public Advertisement findById(final long adId) {
return this.advertisementRepository.get(adId);
}
@Override
public Resource loadMedia(final long adId) throws Exception {
final Advertisement advertisement = this.advertisementRepository.get(adId);
final Path filePath = resolveStoredPath(advertisement.getStoragePath());
final Resource resource = new UrlResource(filePath.toUri());
if (!resource.exists() || !resource.isReadable()) {
throw new EntityNotFoundException("Advertisement media file not found.");
}
return resource;
}
private Path resolveStoredPath(final String storagePath) {
final Path resolved = this.uploadRoot.resolve(storagePath).normalize();
if (!resolved.startsWith(this.uploadRoot)) {
throw new IllegalArgumentException("Invalid storage path.");
}
return resolved;
}
private static String normalizeContentType(final String contentType) {
if (contentType == null || contentType.isBlank()) {
throw new IllegalArgumentException("Missing content type.");
}
return contentType.toLowerCase(Locale.ROOT).split(";")[0].trim();
}
private static AdvertisementMediaType resolveMediaType(final String contentType) {
if (IMAGE_TYPES.contains(contentType)) {
return AdvertisementMediaType.IMAGE;
}
if (VIDEO_TYPES.contains(contentType)) {
return AdvertisementMediaType.VIDEO;
}
throw new IllegalArgumentException("Unsupported media type: " + contentType);
}
private static String sanitizeFileName(final String originalName) {
final String name = Path.of(originalName).getFileName().toString();
final String sanitized = name.replaceAll("[^a-zA-Z0-9._-]", "_");
return sanitized.isBlank() ? "upload" : sanitized;
}
}
@@ -0,0 +1,167 @@
package ba.unsa.etf.si.bbqms.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.Instant;
@Entity
@Table(name = "advertisement")
public class Advertisement {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
@ManyToOne(optional = false)
@JoinColumn(name = "tenant_id", referencedColumnName = "id")
private Tenant tenant;
@Column(name = "title")
private String title;
@Enumerated(EnumType.STRING)
@Column(name = "media_type", nullable = false)
private AdvertisementMediaType mediaType;
@Column(name = "file_name", nullable = false)
private String fileName;
@Column(name = "content_type", nullable = false)
private String contentType;
@Column(name = "storage_path", nullable = false)
private String storagePath;
@Column(name = "sort_order", nullable = false)
private int sortOrder;
@Column(name = "active", nullable = false)
private boolean active;
@Column(name = "duration_seconds", nullable = false)
private int durationSeconds;
@Column(name = "created_at", nullable = false)
private Instant createdAt;
public Advertisement() {
}
public Advertisement(final Tenant tenant,
final String title,
final AdvertisementMediaType mediaType,
final String fileName,
final String contentType,
final String storagePath,
final int sortOrder,
final boolean active,
final int durationSeconds,
final Instant createdAt) {
this.tenant = tenant;
this.title = title;
this.mediaType = mediaType;
this.fileName = fileName;
this.contentType = contentType;
this.storagePath = storagePath;
this.sortOrder = sortOrder;
this.active = active;
this.durationSeconds = durationSeconds;
this.createdAt = createdAt;
}
public long getId() {
return id;
}
public void setId(final long id) {
this.id = id;
}
public Tenant getTenant() {
return tenant;
}
public void setTenant(final Tenant tenant) {
this.tenant = tenant;
}
public String getTitle() {
return title;
}
public void setTitle(final String title) {
this.title = title;
}
public AdvertisementMediaType getMediaType() {
return mediaType;
}
public void setMediaType(final AdvertisementMediaType mediaType) {
this.mediaType = mediaType;
}
public String getFileName() {
return fileName;
}
public void setFileName(final String fileName) {
this.fileName = fileName;
}
public String getContentType() {
return contentType;
}
public void setContentType(final String contentType) {
this.contentType = contentType;
}
public String getStoragePath() {
return storagePath;
}
public void setStoragePath(final String storagePath) {
this.storagePath = storagePath;
}
public int getSortOrder() {
return sortOrder;
}
public void setSortOrder(final int sortOrder) {
this.sortOrder = sortOrder;
}
public boolean isActive() {
return active;
}
public void setActive(final boolean active) {
this.active = active;
}
public int getDurationSeconds() {
return durationSeconds;
}
public void setDurationSeconds(final int durationSeconds) {
this.durationSeconds = durationSeconds;
}
public Instant getCreatedAt() {
return createdAt;
}
public void setCreatedAt(final Instant createdAt) {
this.createdAt = createdAt;
}
}
@@ -0,0 +1,6 @@
package ba.unsa.etf.si.bbqms.domain;
public enum AdvertisementMediaType {
IMAGE,
VIDEO
}
@@ -0,0 +1,11 @@
package ba.unsa.etf.si.bbqms.repository;
import ba.unsa.etf.si.bbqms.domain.Advertisement;
import java.util.List;
public interface AdvertisementRepository extends BaseRepository<Advertisement, Long> {
List<Advertisement> findByTenant_CodeOrderBySortOrderAscIdAsc(String tenantCode);
List<Advertisement> findByTenant_CodeAndActiveTrueOrderBySortOrderAscIdAsc(String tenantCode);
}
@@ -0,0 +1,153 @@
package ba.unsa.etf.si.bbqms.ws.controllers;
import ba.unsa.etf.si.bbqms.admin_service.api.AdvertisementService;
import ba.unsa.etf.si.bbqms.auth_service.api.AuthService;
import ba.unsa.etf.si.bbqms.domain.Advertisement;
import ba.unsa.etf.si.bbqms.ws.models.AdvertisementDto;
import ba.unsa.etf.si.bbqms.ws.models.SimpleMessageDto;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequestMapping("/api/v1/ads")
public class AdvertisementController {
private final AdvertisementService advertisementService;
private final AuthService authService;
public AdvertisementController(final AdvertisementService advertisementService,
final AuthService authService) {
this.advertisementService = advertisementService;
this.authService = authService;
}
@GetMapping("/media/{adId}")
public ResponseEntity streamMedia(@PathVariable final long adId) {
try {
final Advertisement advertisement = this.advertisementService.findById(adId);
final Resource resource = this.advertisementService.loadMedia(adId);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + advertisement.getFileName() + "\"")
.contentType(MediaType.parseMediaType(advertisement.getContentType()))
.body(resource);
} catch (final Exception exception) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/{tenantCode}/active")
public ResponseEntity listActiveAdvertisements(@PathVariable final String tenantCode) {
try {
final List<AdvertisementDto> ads = this.advertisementService.listActiveByTenant(tenantCode).stream()
.map(AdvertisementDto::fromEntity)
.toList();
return ResponseEntity.ok().body(ads);
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
}
}
@PostMapping("/{tenantCode}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity createAdvertisement(@PathVariable final String tenantCode,
@RequestParam("file") final MultipartFile file,
@RequestParam(value = "title", required = false) final String title,
@RequestParam(value = "durationSeconds", required = false) final Integer durationSeconds,
@RequestParam(value = "sortOrder", required = false) final Integer sortOrder,
@RequestParam(value = "active", required = false) final Boolean active) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
final Advertisement created = this.advertisementService.create(
tenantCode,
file,
title,
durationSeconds,
sortOrder,
active
);
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created));
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
}
}
@GetMapping("/{tenantCode}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity listAdvertisements(@PathVariable final String tenantCode) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
final List<AdvertisementDto> ads = this.advertisementService.listByTenant(tenantCode).stream()
.map(AdvertisementDto::fromEntity)
.toList();
return ResponseEntity.ok().body(ads);
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
}
}
@PutMapping("/{tenantCode}/{adId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity updateAdvertisement(@PathVariable final String tenantCode,
@PathVariable final long adId,
@RequestBody final AdvertisementUpdateRequest request) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
final Advertisement updated = this.advertisementService.update(
tenantCode,
adId,
request.title(),
request.durationSeconds(),
request.sortOrder(),
request.active()
);
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated));
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
}
}
@DeleteMapping("/{tenantCode}/{adId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity deleteAdvertisement(@PathVariable final String tenantCode,
@PathVariable final long adId) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
this.advertisementService.delete(tenantCode, adId);
return ResponseEntity.ok().body(new SimpleMessageDto("Deleted advertisement with id: " + adId));
} catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
}
}
public record AdvertisementUpdateRequest(String title,
Integer durationSeconds,
Integer sortOrder,
Boolean active) {
}
}
@@ -27,6 +27,7 @@ import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@@ -186,6 +187,7 @@ public class BranchController {
@GetMapping("/{tenantCode}/{branchId}/queue")
public ResponseEntity getBranchQueue(@PathVariable final String tenantCode,
@PathVariable final String branchId,
@RequestParam(defaultValue = "false") final boolean activeOnly,
final QueueStateParams queueStateParams,
final Sort sort) {
final Branch branch = this.branchService.findById(Long.parseLong(branchId)).orElseThrow();
@@ -193,6 +195,9 @@ public class BranchController {
final TicketRepository ticketRepository = this.ticketService.unwrap(TicketRepository.class);
Specification<Ticket> filter = TicketSpecs.branchIdEquals(branch.getId());
if (activeOnly) {
filter = filter.and(TicketSpecs.deletedEquals(false));
}
if (queueStateParams.serviceId() != null && queueStateParams.serviceId().isPresent()) {
filter = filter.and(TicketSpecs.serviceIdEquals(queueStateParams.serviceId().get()));
}
@@ -0,0 +1,34 @@
package ba.unsa.etf.si.bbqms.ws.models;
import ba.unsa.etf.si.bbqms.domain.Advertisement;
import ba.unsa.etf.si.bbqms.domain.AdvertisementMediaType;
import java.time.Instant;
public record AdvertisementDto(
long id,
String title,
AdvertisementMediaType mediaType,
String fileName,
String contentType,
String mediaUrl,
int sortOrder,
boolean active,
int durationSeconds,
Instant createdAt
) {
public static AdvertisementDto fromEntity(final Advertisement advertisement) {
return new AdvertisementDto(
advertisement.getId(),
advertisement.getTitle(),
advertisement.getMediaType(),
advertisement.getFileName(),
advertisement.getContentType(),
"/api/v1/ads/media/" + advertisement.getId(),
advertisement.getSortOrder(),
advertisement.isActive(),
advertisement.getDurationSeconds(),
advertisement.getCreatedAt()
);
}
}
+12 -6
View File
@@ -1,14 +1,18 @@
spring:
application:
name: bbqms
name: qms
jpa:
database: mysql
hibernate:
ddl-auto: none
datasource:
url: jdbc:mysql://localhost:3306/bbqms
url: jdbc:mysql://localhost:3306/qms
username: root
password: password
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
security:
oauth2:
client:
@@ -16,19 +20,21 @@ spring:
google:
client-id: dummy-google-client-id
flyway:
schemas: bbqms
schemas: qms
jwt:
header-title: Authorization
token-prefix: Bearer
secret-key: dummy-jwt-secret-key-for-local-dev-only-min-32-chars
secret-key: a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF
authorities-key: USER_AUTHORITIES
token-validity-time: PT30M
tfa:
label: BBQMS
issuer: BBQMS
label: QMS
issuer: QMS
tenancy:
default-code: DFLT
notifications:
expo-url: https://exp.host/--/api/v2/push/send
mock: true
ads:
upload-dir: uploads/ads
@@ -0,0 +1,19 @@
BEGIN;
CREATE TABLE IF NOT EXISTS advertisement
(
id INTEGER PRIMARY KEY AUTO_INCREMENT,
tenant_id INTEGER NOT NULL,
title VARCHAR(255) NULL,
media_type VARCHAR(32) NOT NULL,
file_name VARCHAR(512) NOT NULL,
content_type VARCHAR(128) NOT NULL,
storage_path VARCHAR(1024) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
active BOOL NOT NULL DEFAULT TRUE,
duration_seconds INTEGER NOT NULL DEFAULT 10,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT FK_advertisement_tenant FOREIGN KEY (tenant_id) REFERENCES tenant (id)
);
COMMIT;
+3
View File
@@ -10,9 +10,12 @@
"web": "expo start --web"
},
"dependencies": {
"@babel/runtime": "^7.29.7",
"@expo/metro-runtime": "~3.1.3",
"@expo/vector-icons": "^14.0.4",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native-community/masked-view": "^0.1.11",
"@react-native/assets-registry": "0.73.1",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/native": "^6.1.17",
"@react-navigation/stack": "^6.3.29",
+10492
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,4 +3,4 @@
"compilerOptions": {
"strict": true
}
}
}
+15 -12
View File
@@ -1,14 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBQMS Admin App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyKOPKB QMS-Admin App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+2
View File
@@ -14,6 +14,7 @@
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"formik": "^2.4.5",
"qrcode": "^1.5.4",
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0",
@@ -24,6 +25,7 @@
"yup": "^1.4.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
+203 -5
View File
@@ -20,6 +20,9 @@ importers:
formik:
specifier: ^2.4.5
version: 2.4.9(@types/react@18.3.31)(react@18.3.1)
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^18.2.0
version: 18.3.1
@@ -45,6 +48,9 @@ importers:
specifier: ^1.4.0
version: 1.7.1
devDependencies:
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: ^18.2.64
version: 18.3.31
@@ -53,7 +59,7 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.2.1
version: 4.7.0(vite@5.4.21)
version: 4.7.0(vite@5.4.21(@types/node@26.1.1))
eslint:
specifier: ^8.57.0
version: 8.57.1
@@ -68,7 +74,7 @@ importers:
version: 0.4.26(eslint@8.57.1)
vite:
specifier: ^5.1.6
version: 5.4.21
version: 5.4.21(@types/node@26.1.1)
packages:
@@ -572,9 +578,15 @@ packages:
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
@@ -707,6 +719,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
@@ -717,6 +733,9 @@ packages:
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -766,6 +785,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -785,6 +808,9 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -806,6 +832,9 @@ packages:
electron-to-chromium@1.5.393:
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
es-abstract-get@1.0.0:
resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
engines: {node: '>= 0.4'}
@@ -922,6 +951,10 @@ packages:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -968,6 +1001,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1103,6 +1140,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
@@ -1210,6 +1251,10 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1298,14 +1343,26 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1328,6 +1385,10 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -1355,6 +1416,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1445,6 +1511,13 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1505,6 +1578,9 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -1549,6 +1625,10 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@@ -1641,6 +1721,9 @@ packages:
peerDependencies:
react: '>=16.14.0'
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -1705,6 +1788,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'}
@@ -1718,12 +1804,27 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2160,8 +2261,16 @@ snapshots:
'@types/lodash@4.17.24': {}
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
'@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 26.1.1
'@types/react-dom@18.3.7(@types/react@18.3.31)':
dependencies:
'@types/react': 18.3.31
@@ -2183,7 +2292,7 @@ snapshots:
'@ungap/structured-clone@1.3.3': {}
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@26.1.1))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -2191,7 +2300,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21
vite: 5.4.21(@types/node@26.1.1)
transitivePeerDependencies:
- supports-color
@@ -2325,6 +2434,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001806: {}
chalk@4.1.2:
@@ -2334,6 +2445,12 @@ snapshots:
classnames@2.5.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -2380,6 +2497,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
deep-is@0.1.4: {}
deepmerge@2.2.1: {}
@@ -2398,6 +2517,8 @@ snapshots:
dequal@2.0.3: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -2423,6 +2544,8 @@ snapshots:
electron-to-chromium@1.5.393: {}
emoji-regex@8.0.0: {}
es-abstract-get@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -2676,6 +2799,11 @@ snapshots:
dependencies:
flat-cache: 3.2.0
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -2732,6 +2860,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -2884,6 +3014,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
@@ -2991,6 +3123,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -3087,14 +3223,24 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -3109,6 +3255,8 @@ snapshots:
picocolors@1.1.1: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.5.20:
@@ -3135,6 +3283,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
react-aria@3.50.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -3255,6 +3409,10 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve@2.0.0-next.7:
@@ -3366,6 +3524,8 @@ snapshots:
semver@6.3.1: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -3429,6 +3589,12 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.9
@@ -3556,6 +3722,8 @@ snapshots:
dependencies:
react: 18.3.1
undici-types@8.3.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.6):
dependencies:
browserslist: 4.28.6
@@ -3572,12 +3740,13 @@ snapshots:
validator@13.15.35: {}
vite@5.4.21:
vite@5.4.21(@types/node@26.1.1):
dependencies:
esbuild: 0.21.5
postcss: 8.5.20
rollup: 4.62.2
optionalDependencies:
'@types/node': 26.1.1
fsevents: 2.3.3
warning@4.0.3:
@@ -3615,6 +3784,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.22:
dependencies:
available-typed-arrays: 1.0.7
@@ -3631,10 +3802,37 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
y18n@4.0.3: {}
yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yocto-queue@0.1.0: {}
yup@1.7.1:
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+11
View File
@@ -19,6 +19,7 @@ import ManageBranches from './pages/ManageBranchesScreen/ManageBranchesScreen';
import ManageGroups from './pages/ManageGroupsScreen/ManageGroupsScreen';
import ManageStations from './pages/ManageStationScreen/ManageStationScreen';
import ManageDisplays from './pages/ManageDisplays/ManageDisplays';
import ManageAdsScreen from './pages/ManageAdsScreen/ManageAdsScreen';
import ManageUsers from './pages/UserManagingScreen/UserManagingScreen';
import ViewQueues from './pages/ViewBranchQueues/ViewBranchQueues';
import { ROLES } from './constants.js';
@@ -55,6 +56,10 @@ export default function App() {
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageDisplays />
</AuthGuard>} />
<Route exact path="/:tenantCode/manage/ads" element={
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageAdsScreen />
</AuthGuard>} />
<Route exact path="/:tenantCode/manage/stations" element={
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageStations />
@@ -121,6 +126,12 @@ export default function App() {
buttonColor="var(--light-blue)"
url={`/${user.tenantCode}/manage/displays`}
/>
<HomePageCard
title="Manage advertisements"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={`/${user.tenantCode}/manage/ads`}
/>
<HomePageCard
title="Manage groups"
backgroundColor="var(--light-blue)"
@@ -2,7 +2,6 @@ import { useContext } from 'react';
import { Button } from 'react-bootstrap';
import { useNavigate } from 'react-router-dom';
import { lastPathPart } from '../../utils/StringUtils.js';
import profileImage from '../../../assets/profile-user.png'
import './Header.css';
import { UserContext } from '../../context/UserContext.jsx';
@@ -35,28 +34,31 @@ export default function Header() {
return (
<>
<header className="main-header">
<h2 className="header-logo" onClick={ goHome }>BBQMS</h2>
<h2 className="header-logo" onClick={goHome}>
MyKOPKB QMS-Admin
</h2>
<div className="header-logout">
{ !!user && (
<button className="header-logout-btn" onClick={ handleLogout }>
{!!user && (
<button
className="header-logout-btn"
onClick={handleLogout}
>
Logout
</button>
) }
</div>
<div className="header-profile" onClick={ () => navigate('/profile') }>
<img src={ profileImage } className="header-profile-png" alt="Profile image" />
)}
</div>
</header>
{ showBackButton && (
<Button variant="secondary"
className="mt-2 px-4"
onClick={ goHome }>
{showBackButton && (
<Button
variant="secondary"
className="mt-2 px-4"
onClick={goHome}
>
Back
</Button>
) }
)}
</>
);
}
}
+3
View File
@@ -1,5 +1,8 @@
export const SERVER_URL = 'http://localhost:8080';
export const CUSTOMER_APP_URL =
import.meta.env.VITE_CUSTOMER_APP_URL ?? 'http://localhost:3000';
export const ROLES = {
ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
@@ -0,0 +1,35 @@
import { getToken, setToken } from '../utils/session.js';
/**
* Multipart upload helper. Do not set Content-Type manually — the browser
* must add the multipart boundary.
*/
export async function uploadFormData(url, formData, method = 'POST') {
const headers = new Headers();
const token = getToken();
if (token) {
headers.append('Authorization', `Bearer ${token}`);
}
const res = await fetch(url, {
method,
headers,
body: formData,
});
let data = null;
try {
data = await res.json();
} catch {
data = null;
}
if (res.ok) {
const newToken = res.headers.get('Auth-Token');
if (newToken) {
setToken(newToken);
}
}
return { data, success: res.ok };
}
@@ -4,7 +4,7 @@ import { SERVER_URL } from '../../constants';
import { getIsTfa, getUserData, setIsTfa } from '../../utils/session.js';
import "./AdminProfile.css"
export default function AdminProfile(){
export default function AdminProfile() {
const [isChecked, setIsChecked] = useState(false);
const [isQRCodeEnabled, setIsQRCodeEnabled] = useState(false);
const [qrCodeSrc, setQrCodeSrc] = useState('');
@@ -18,40 +18,40 @@ export default function AdminProfile(){
setIsQRCodeEnabled(isTfa);
}, []);
const handleSaveChanges = async () =>{
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
const handleSaveChanges = async () => {
const url = `${SERVER_URL}/api/v1/auth/tfa`;
const { data, success } = await fetchData(url, 'PUT', {
isTfa: isChecked
});
setIsTfa(isChecked);
setIsQRCodeEnabled(isChecked);
if(!isChecked){
if (!isChecked) {
setQrCodeSrc('');
}
if(success){
if (success) {
let message = 'Success: Your changes have been successfully submitted.';
if(isChecked){
if (isChecked) {
message = message + '\nPlease scan QR code.';
}
alert(message);
}else{
} else {
alert('An error occurred. Please try again.');
}
}
const handleCheckBoxChange = () =>{
const handleCheckBoxChange = () => {
setIsChecked(!isChecked);
}
const handleGenerateQRCode = () =>{
if(isQRCodeEnabled){
const url = `${ SERVER_URL }/api/v1/auth/tfa?email=${userData.email}`;
const handleGenerateQRCode = () => {
if (isQRCodeEnabled) {
const url = `${SERVER_URL}/api/v1/auth/tfa?email=${userData.email}`;
fetchData(url, 'GET')
.then(({ data, success }) => {
if (success) {
setQrCodeSrc(data.qrCode);
}
});
.then(({ data, success }) => {
if (success) {
setQrCodeSrc(data.qrCode);
}
});
}
}
@@ -59,17 +59,17 @@ export default function AdminProfile(){
<div id="account-settings">
<h1>Account settings</h1>
<div id="check-box">
<form>
{/* <form>
<label htmlFor="2fa">Use two-factor authentication:</label>
<input type="checkbox" id="2fa" name="2fa" checked={isChecked} onChange={handleCheckBoxChange}></input>
</form>
</form> */}
</div>
<div id="QR-code">
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode}/>
{/* <div id="QR-code">
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode} />
<img src={qrCodeSrc}></img>
</div>
</div> */}
<div>
<input type="submit" value="Save changes" onClick={handleSaveChanges}/>
<input type="submit" value="Save changes" onClick={handleSaveChanges} />
</div>
</div>
);
@@ -0,0 +1,62 @@
.ad-tv-preview {
text-align: left;
border-radius: 0.75rem;
border: 1px solid #334155;
background: #0f172a;
color: #f8fafc;
padding: 0.85rem;
}
.ad-tv-preview__chrome {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.65rem;
}
.ad-tv-preview__label {
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #94a3b8;
}
.ad-tv-preview__ratio {
font-size: 0.8rem;
color: #cbd5e1;
}
.ad-tv-preview__stage {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 0.5rem;
overflow: hidden;
background: #1e293b;
display: flex;
align-items: center;
justify-content: center;
}
.ad-tv-preview__media {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #0f172a;
}
.ad-tv-preview__empty {
margin: 0;
padding: 1rem;
color: #94a3b8;
font-size: 0.95rem;
text-align: center;
}
.ad-tv-preview__caption {
margin: 0.65rem 0 0;
text-align: center;
font-size: 0.95rem;
color: #cbd5e1;
}
@@ -0,0 +1,597 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button, Form, Modal, Table } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { fetchData } from '../../fetching/Fetch.js';
import { uploadFormData } from '../../fetching/uploadFormData.js';
import { SERVER_URL } from '../../constants.js';
import './ManageAdsScreen.css';
const styles = {
primaryButton: {
backgroundColor: '#548CA8',
borderColor: '#548CA8',
},
infoButton: {
backgroundColor: '#548CA8',
color: 'white',
borderColor: '#548CA8',
},
modalHeader: {
backgroundColor: '#334257',
color: 'white',
},
};
const ACCEPTED_TYPES = 'image/jpeg,image/png,image/webp,image/gif,video/mp4,video/webm';
/** Matches branch TV ad box (see BranchDisplayPage `.branch-display__ad-stage`). */
const AD_DISPLAY_SPEC = {
aspectRatio: '16:10',
recommendedSize: '1920 × 1200 px',
minSize: '1280 × 800 px',
};
function AdDisplayGuidelines() {
return (
<div
className="text-start small rounded border p-3 mb-3"
style={{ backgroundColor: '#f8fafc', borderColor: '#cbd5e1' }}
>
<strong>TV display size guide</strong>
<ul className="mb-0 mt-2 ps-3">
<li>
Aspect ratio: <strong>{AD_DISPLAY_SPEC.aspectRatio}</strong> (width : height)
</li>
<li>
Recommended resolution: <strong>{AD_DISPLAY_SPEC.recommendedSize}</strong>
</li>
<li>
Minimum for clear TV: <strong>{AD_DISPLAY_SPEC.minSize}</strong>
</li>
<li>
Use landscape media that fills the frame. Other ratios will show empty bars
or look cropped on the branch display.
</li>
</ul>
</div>
);
}
function mediaUrlFromAd(ad) {
if (!ad?.mediaUrl) return null;
return ad.mediaUrl.startsWith('http')
? ad.mediaUrl
: `${SERVER_URL}${ad.mediaUrl}`;
}
function isVideoFile(file) {
return Boolean(file?.type?.startsWith('video/'));
}
/**
* Mimics the branch TV advertisement panel (16:10 stage).
*/
function AdTvPreview({ src, isVideo, title, emptyLabel = 'Select a file to preview' }) {
return (
<div className="ad-tv-preview">
<div className="ad-tv-preview__chrome">
<span className="ad-tv-preview__label">Branch TV preview</span>
<span className="ad-tv-preview__ratio">{AD_DISPLAY_SPEC.aspectRatio}</span>
</div>
<div className="ad-tv-preview__stage">
{!src ? (
<p className="ad-tv-preview__empty">{emptyLabel}</p>
) : isVideo ? (
<video
key={src}
className="ad-tv-preview__media"
src={src}
controls
muted
playsInline
/>
) : (
<img
key={src}
className="ad-tv-preview__media"
src={src}
alt={title || 'Advertisement preview'}
/>
)}
</div>
{title ? <p className="ad-tv-preview__caption">{title}</p> : null}
</div>
);
}
export default function ManageAdsScreen() {
const { tenantCode } = useParams();
const [ads, setAds] = useState([]);
const [errorMessage, setErrorMessage] = useState('');
const [loading, setLoading] = useState(false);
const [showUpload, setShowUpload] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [selectedAd, setSelectedAd] = useState(null);
const [file, setFile] = useState(null);
const [title, setTitle] = useState('');
const [durationSeconds, setDurationSeconds] = useState(10);
const [sortOrder, setSortOrder] = useState('');
const [active, setActive] = useState(true);
const url = `${SERVER_URL}/api/v1/ads/${encodeURIComponent(tenantCode)}`;
const uploadObjectUrl = useMemo(() => {
if (!file) return null;
return URL.createObjectURL(file);
}, [file]);
useEffect(() => {
return () => {
if (uploadObjectUrl) {
URL.revokeObjectURL(uploadObjectUrl);
}
};
}, [uploadObjectUrl]);
const loadAds = useCallback(async () => {
try {
const response = await fetchData(url, 'GET');
if (!response.success) {
setErrorMessage('Failed to load advertisements.');
return;
}
setAds(Array.isArray(response.data) ? response.data : []);
} catch (error) {
console.error(error);
setErrorMessage('Failed to load advertisements.');
}
}, [url]);
useEffect(() => {
loadAds();
}, [loadAds]);
function resetUploadForm() {
setFile(null);
setTitle('');
setDurationSeconds(10);
setSortOrder('');
setActive(true);
}
function openEdit(ad) {
setSelectedAd(ad);
setTitle(ad.title ?? '');
setDurationSeconds(ad.durationSeconds ?? 10);
setSortOrder(String(ad.sortOrder ?? 0));
setActive(Boolean(ad.active));
setShowEdit(true);
}
function openPreview(ad) {
setSelectedAd(ad);
setShowPreview(true);
}
async function handleUpload(event) {
event.preventDefault();
if (!file) {
setErrorMessage('Choose an image or video file to upload.');
return;
}
setLoading(true);
try {
const formData = new FormData();
formData.append('file', file);
if (title.trim()) {
formData.append('title', title.trim());
}
formData.append('durationSeconds', String(durationSeconds || 10));
formData.append('active', String(active));
if (sortOrder !== '' && !Number.isNaN(Number(sortOrder))) {
formData.append('sortOrder', String(Number(sortOrder)));
}
const response = await uploadFormData(url, formData, 'POST');
if (!response.success) {
setErrorMessage(response.data?.message || 'Failed to upload advertisement.');
return;
}
setShowUpload(false);
resetUploadForm();
await loadAds();
} catch (error) {
console.error(error);
setErrorMessage('Failed to upload advertisement.');
} finally {
setLoading(false);
}
}
async function handleEdit(event) {
event.preventDefault();
if (!selectedAd) return;
setLoading(true);
try {
const response = await fetchData(`${url}/${selectedAd.id}`, 'PUT', {
title: title.trim() || null,
durationSeconds: Number(durationSeconds) || 10,
sortOrder: sortOrder === '' ? null : Number(sortOrder),
active,
});
if (!response.success) {
setErrorMessage(response.data?.message || 'Failed to update advertisement.');
return;
}
setShowEdit(false);
setSelectedAd(null);
await loadAds();
} catch (error) {
console.error(error);
setErrorMessage('Failed to update advertisement.');
} finally {
setLoading(false);
}
}
async function handleDelete() {
if (!selectedAd) return;
setLoading(true);
try {
const response = await fetchData(`${url}/${selectedAd.id}`, 'DELETE');
if (!response.success) {
setErrorMessage(response.data?.message || 'Failed to delete advertisement.');
return;
}
setShowDelete(false);
setSelectedAd(null);
await loadAds();
} catch (error) {
console.error(error);
setErrorMessage('Failed to delete advertisement.');
} finally {
setLoading(false);
}
}
return (
<div className="text-center">
<h2>Manage Advertisements</h2>
<p className="text-muted mb-2">
Ads are shared across all branches for this company.
</p>
<div className="mx-auto mb-3" style={{ maxWidth: 640 }}>
<AdDisplayGuidelines />
</div>
<Button
variant="primary"
style={styles.primaryButton}
className="mb-3"
onClick={() => {
resetUploadForm();
setShowUpload(true);
}}
>
Upload Ad
</Button>
<Table striped bordered hover>
<thead>
<tr>
<th>Preview</th>
<th>Title</th>
<th>Type</th>
<th>Order</th>
<th>Duration (s)</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{ads.length === 0 ? (
<tr>
<td colSpan={7} className="text-muted">
No advertisements yet.
</td>
</tr>
) : (
ads.map((ad) => (
<tr key={ad.id}>
<td style={{ width: 120 }}>
{ad.mediaType === 'IMAGE' ? (
<img
src={mediaUrlFromAd(ad)}
alt={ad.title || ad.fileName}
style={{
maxWidth: 100,
maxHeight: 60,
objectFit: 'cover',
cursor: 'pointer',
}}
onClick={() => openPreview(ad)}
/>
) : (
<Button
variant="link"
className="p-0"
onClick={() => openPreview(ad)}
>
Video
</Button>
)}
</td>
<td>{ad.title || ad.fileName}</td>
<td>{ad.mediaType}</td>
<td>{ad.sortOrder}</td>
<td>{ad.durationSeconds}</td>
<td>{ad.active ? 'Yes' : 'No'}</td>
<td>
<Button
variant="info"
style={styles.infoButton}
onClick={() => openPreview(ad)}
>
Preview
</Button>{' '}
<Button
variant="info"
style={styles.infoButton}
onClick={() => openEdit(ad)}
>
Edit
</Button>{' '}
<Button
variant="danger"
onClick={() => {
setSelectedAd(ad);
setShowDelete(true);
}}
>
Delete
</Button>
</td>
</tr>
))
)}
</tbody>
</Table>
<Modal
show={showUpload}
onHide={() => {
setShowUpload(false);
resetUploadForm();
}}
size="lg"
>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>UPLOAD ADVERTISEMENT</Modal.Title>
</Modal.Header>
<Form onSubmit={handleUpload}>
<Modal.Body>
<AdDisplayGuidelines />
<AdTvPreview
src={uploadObjectUrl}
isVideo={isVideoFile(file)}
title={title.trim() || file?.name}
/>
<Form.Group className="mb-3 mt-3">
<Form.Label>File (image or video)</Form.Label>
<Form.Control
type="file"
accept={ACCEPTED_TYPES}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
required
/>
<Form.Text className="text-muted">
Best fit: {AD_DISPLAY_SPEC.aspectRatio},{' '}
{AD_DISPLAY_SPEC.recommendedSize}
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Title</Form.Label>
<Form.Control
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Optional"
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Duration seconds (images)</Form.Label>
<Form.Control
type="number"
min={1}
value={durationSeconds}
onChange={(event) => setDurationSeconds(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Sort order</Form.Label>
<Form.Control
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
placeholder="Auto if empty"
/>
</Form.Group>
<Form.Check
type="switch"
id="upload-active"
label="Active"
checked={active}
onChange={(event) => setActive(event.target.checked)}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setShowUpload(false);
resetUploadForm();
}}
>
Close
</Button>
<Button
type="submit"
variant="primary"
style={styles.primaryButton}
disabled={loading}
>
{loading ? 'Uploading…' : 'Upload'}
</Button>
</Modal.Footer>
</Form>
</Modal>
<Modal show={showEdit} onHide={() => setShowEdit(false)} size="lg">
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>EDIT ADVERTISEMENT</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEdit}>
<Modal.Body>
{selectedAd ? (
<AdTvPreview
src={mediaUrlFromAd(selectedAd)}
isVideo={selectedAd.mediaType === 'VIDEO'}
title={title.trim() || selectedAd.fileName}
/>
) : null}
<Form.Group className="mb-3 mt-3">
<Form.Label>Title</Form.Label>
<Form.Control
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Duration seconds (images)</Form.Label>
<Form.Control
type="number"
min={1}
value={durationSeconds}
onChange={(event) => setDurationSeconds(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Sort order</Form.Label>
<Form.Control
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</Form.Group>
<Form.Check
type="switch"
id="edit-active"
label="Active"
checked={active}
onChange={(event) => setActive(event.target.checked)}
/>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowEdit(false)}>
Close
</Button>
<Button
type="submit"
variant="primary"
style={styles.primaryButton}
disabled={loading}
>
{loading ? 'Saving…' : 'Save'}
</Button>
</Modal.Footer>
</Form>
</Modal>
<Modal
show={showPreview}
onHide={() => {
setShowPreview(false);
setSelectedAd(null);
}}
size="lg"
centered
>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>TV DISPLAY PREVIEW</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedAd ? (
<AdTvPreview
src={mediaUrlFromAd(selectedAd)}
isVideo={selectedAd.mediaType === 'VIDEO'}
title={selectedAd.title || selectedAd.fileName}
/>
) : null}
<p className="text-muted small mt-3 mb-0 text-center">
This matches the advertisement panel on the branch waiting-area TV
({AD_DISPLAY_SPEC.aspectRatio}).
</p>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setShowPreview(false);
setSelectedAd(null);
}}
>
Close
</Button>
</Modal.Footer>
</Modal>
<Modal show={showDelete} onHide={() => setShowDelete(false)}>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>CONFIRMATION</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
Delete advertisement{' '}
<strong>{selectedAd?.title || selectedAd?.fileName}</strong>?
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowDelete(false)}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete} disabled={loading}>
{loading ? 'Deleting…' : 'Delete'}
</Button>
</Modal.Footer>
</Modal>
<Modal
show={errorMessage !== ''}
onHide={() => setErrorMessage('')}
backdrop="static"
keyboard={false}
>
<Modal.Header closeButton style={{ backgroundColor: '#dc3545', color: 'white' }}>
<Modal.Title>ERROR</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{errorMessage}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setErrorMessage('')}>
Close
</Button>
</Modal.Footer>
</Modal>
</div>
);
}
@@ -4,6 +4,11 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import { fetchData } from '../../fetching/Fetch.js';
import { useParams } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
import {
downloadBranchQr,
generateBranchQrDataUrl,
getBranchTicketUrl,
} from '../../utils/branchQr.js';
const styles = {
primaryButton: {
@@ -30,6 +35,9 @@ const ManageBranchesScreen = () => {
const [newStationName, setNewStationName] = useState('');
const [deleteConfirmation, setDeleteConfirmation] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [qrModalBranch, setQrModalBranch] = useState(null);
const [qrDataUrl, setQrDataUrl] = useState('');
const [qrLoading, setQrLoading] = useState(false);
const { tenantCode } = useParams();
const url = `${SERVER_URL}/api/v1/branches/${tenantCode}`;
@@ -154,6 +162,42 @@ const ManageBranchesScreen = () => {
setSelectedBranchIndex(index);
};
const handleShowQr = async (branch) => {
setQrModalBranch(branch);
setQrDataUrl('');
setQrLoading(true);
try {
const dataUrl = await generateBranchQrDataUrl(tenantCode, branch.id);
setQrDataUrl(dataUrl);
} catch (error) {
console.error('Error:', error);
setQrModalBranch(null);
setErrorMessage('Failed to generate branch QR code.');
} finally {
setQrLoading(false);
}
};
const handleDownloadQr = async () => {
if (!qrModalBranch) return;
try {
await downloadBranchQr({
tenantCode,
branchId: qrModalBranch.id,
branchName: qrModalBranch.name,
});
} catch (error) {
console.error('Error:', error);
setErrorMessage('Failed to download branch QR code.');
}
};
const handleCloseQrModal = () => {
setQrModalBranch(null);
setQrDataUrl('');
setQrLoading(false);
};
const confirmDeleteBranch = async () => {
const branchId = manageBranches[selectedBranchIndex].id;
const urlToDelete = `${url}/${branchId}`;
@@ -221,6 +265,7 @@ const ManageBranchesScreen = () => {
<td>{branch.tellerStations ? branch.tellerStations.map(station => station.name).join(', ') : '-'}</td>
<td>
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
<Button variant="info" style={styles.infoButton} onClick={() => handleShowQr(branch)}>QR</Button>{' '}
<Button variant="danger" onClick={() => handleDeleteBranch(index)}>Delete</Button>
</td>
</tr>
@@ -270,6 +315,46 @@ const ManageBranchesScreen = () => {
</Modal.Footer>
</Modal>
<Modal show={qrModalBranch !== null} onHide={handleCloseQrModal}>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>
BRANCH QR{qrModalBranch ? `${qrModalBranch.name}` : ''}
</Modal.Title>
</Modal.Header>
<Modal.Body className="text-center">
{qrLoading ? (
<p>Generating QR</p>
) : qrDataUrl ? (
<>
<img
src={qrDataUrl}
alt={`QR code for ${qrModalBranch?.name}`}
style={{ width: 280, height: 280, maxWidth: '100%' }}
/>
<p className="mt-3 mb-0 text-break small text-muted">
{qrModalBranch
? getBranchTicketUrl(tenantCode, qrModalBranch.id)
: ''}
</p>
<p className="mt-2 mb-0 small">
Print this QR and place it at the branch. Customers scan it to choose a service.
</p>
</>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleCloseQrModal}>Close</Button>
<Button
variant="primary"
style={styles.primaryButton}
onClick={handleDownloadQr}
disabled={!qrDataUrl || qrLoading}
>
Download PNG
</Button>
</Modal.Footer>
</Modal>
<Modal show={deleteConfirmation} onHide={() => setDeleteConfirmation(false)}>
<Modal.Header closeButton style={{ backgroundColor: '#334257', color: 'white' }}>
<Modal.Title>CONFIRMATION</Modal.Title>
+93
View File
@@ -0,0 +1,93 @@
import QRCode from 'qrcode';
import { CUSTOMER_APP_URL } from '../constants.js';
const DEFAULT_QR_SECRET = 'qms-branch-qr-v1';
function getQrSecret() {
return import.meta.env.VITE_QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
}
function toBase64Url(bytes) {
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function fromBase64Url(token) {
const padded = token.replace(/-/g, '+').replace(/_/g, '/');
const padLength = (4 - (padded.length % 4)) % 4;
const base64 = padded + '='.repeat(padLength);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function xorBytes(bytes, secret) {
const key = new TextEncoder().encode(secret);
return bytes.map((byte, index) => byte ^ key[index % key.length]);
}
/** Opaque token so QR URLs don't expose tenant code / branch id. */
export function encodeBranchQrToken(tenantCode, branchId, secret = getQrSecret()) {
const plain = `v1:${String(tenantCode).trim().toUpperCase()}:${Number(branchId)}`;
const plainBytes = new TextEncoder().encode(plain);
return toBase64Url(xorBytes(plainBytes, secret));
}
export function decodeBranchQrToken(token, secret = getQrSecret()) {
try {
const plain = new TextDecoder().decode(xorBytes(fromBase64Url(token), secret));
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
if (!match) return null;
return {
tenantCode: match[1],
branchId: Number(match[2]),
};
} catch {
return null;
}
}
export function getBranchTicketUrl(tenantCode, branchId) {
const base = CUSTOMER_APP_URL.replace(/\/$/, '');
const token = encodeBranchQrToken(tenantCode, branchId);
return `${base}/q/${token}`;
}
export async function generateBranchQrDataUrl(tenantCode, branchId) {
return QRCode.toDataURL(getBranchTicketUrl(tenantCode, branchId), {
errorCorrectionLevel: 'M',
margin: 2,
width: 1024,
color: {
dark: '#000000',
light: '#ffffff',
},
});
}
function slugify(value) {
return String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'branch';
}
export function downloadDataUrl(dataUrl, filename) {
const link = document.createElement('a');
link.href = dataUrl;
link.download = filename;
link.click();
}
export async function downloadBranchQr({ tenantCode, branchId, branchName }) {
const dataUrl = await generateBranchQrDataUrl(tenantCode, branchId);
downloadDataUrl(dataUrl, `qr-${slugify(branchName)}-${branchId}.png`);
return dataUrl;
}
-3
View File
@@ -30,9 +30,6 @@ yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+2 -2
View File
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Customer Queue",
description: "Take a queue ticket for your branch and service",
title: "MyKOPKB QMS-Customer App",
description: "MyKOPKB QMS-Customer App",
};
export default function RootLayout({
@@ -0,0 +1,42 @@
import Link from "next/link";
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
import { decodeBranchQrToken } from "@/lib/branchToken";
type PageProps = {
params: Promise<{
token: string;
}>;
};
export default async function BranchQrPage({ params }: PageProps) {
const { token } = await params;
const payload = decodeBranchQrToken(token);
if (!payload) {
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
<p className="text-zinc-600">
This branch QR link is not valid. Scan again or enter a company code.
</p>
<Link
href="/"
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
>
Enter company code
</Link>
</main>
</div>
);
}
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<CustomerTicketFlow
initialTenantCode={payload.tenantCode}
initialBranchId={payload.branchId}
/>
</div>
);
}
@@ -0,0 +1,42 @@
import Link from "next/link";
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
type PageProps = {
params: Promise<{
tenantCode: string;
branchId: string;
}>;
};
export default async function BranchTicketPage({ params }: PageProps) {
const { tenantCode, branchId: branchIdParam } = await params;
const branchId = Number(branchIdParam);
if (!Number.isFinite(branchId)) {
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
<p className="text-zinc-600">
This branch QR link is not valid. Scan again or enter a company code.
</p>
<Link
href="/"
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
>
Enter company code
</Link>
</main>
</div>
);
}
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<CustomerTicketFlow
initialTenantCode={tenantCode}
initialBranchId={branchId}
/>
</div>
);
}
@@ -1,6 +1,7 @@
"use client";
import { FormEvent, useState } from "react";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import {
Branch,
Service,
@@ -10,21 +11,134 @@ import {
getBranchServices,
getBranches,
getTenant,
getTicketsForDevice,
} from "@/lib/api";
import { getDeviceToken } from "@/lib/device";
type Step = "tenant" | "branch" | "service" | "ticket";
export default function CustomerTicketFlow() {
const [step, setStep] = useState<Step>("tenant");
const [tenantCode, setTenantCode] = useState("DFLT");
type Props = {
initialTenantCode?: string;
initialBranchId?: number;
};
function pickLatestTicket(
tickets: Ticket[],
branchId?: number
): Ticket | null {
const scoped =
branchId != null
? tickets.filter((item) => item.branch.id === branchId)
: tickets;
if (scoped.length === 0) {
return null;
}
return [...scoped].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)[0];
}
export default function CustomerTicketFlow({
initialTenantCode,
initialBranchId,
}: Props) {
const fromQr =
initialTenantCode != null &&
initialTenantCode !== "" &&
initialBranchId != null &&
Number.isFinite(initialBranchId);
const [step, setStep] = useState<Step>(fromQr ? "service" : "tenant");
const [tenantCode, setTenantCode] = useState(
initialTenantCode?.trim().toUpperCase() ?? ""
);
const [tenant, setTenant] = useState<Tenant | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const [ticket, setTicket] = useState<Ticket | null>(null);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const bootstrapped = useRef(false);
useEffect(() => {
if (bootstrapped.current) return;
bootstrapped.current = true;
async function bootstrap() {
setLoading(true);
setError(null);
try {
const deviceToken = getDeviceToken();
if (deviceToken) {
const responses = await getTicketsForDevice(deviceToken);
const latest = pickLatestTicket(
responses.map((response) => response.ticket),
fromQr ? initialBranchId : undefined
);
if (latest) {
setTicket(latest);
setSelectedBranch({
id: latest.branch.id,
name: latest.branch.name,
tellerStations: latest.branch.tellerStations ?? [],
});
setStep("ticket");
setLoading(false);
return;
}
}
} catch {
// No saved ticket — continue into the normal flow.
}
if (!fromQr) {
setLoading(false);
return;
}
const code = initialTenantCode!.trim().toUpperCase();
const branchId = initialBranchId!;
try {
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
const branch = branchData.find((item) => item.id === branchId);
if (!branch) {
setError(
"This branch was not found. Scan the QR again or enter a company code."
);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
return;
}
const branchServices = await getBranchServices(code, branch.id);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setSelectedBranch(branch);
setServices(branchServices);
setStep("service");
} catch {
setError(
"Could not open this branch link. Scan the QR again or enter a company code."
);
setStep("tenant");
} finally {
setLoading(false);
}
}
void bootstrap();
}, [fromQr, initialTenantCode, initialBranchId]);
async function handleTenantSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -84,6 +198,7 @@ export default function CustomerTicketFlow() {
deviceToken: getDeviceToken(),
});
setTicket(response.ticket);
setSelectedBranch(response.ticket.branch);
setStep("ticket");
} catch {
setError(
@@ -94,16 +209,66 @@ export default function CustomerTicketFlow() {
}
}
function resetFlow() {
setStep(tenant ? "branch" : "tenant");
setSelectedBranch(null);
setServices([]);
async function resetFlow() {
setTicket(null);
setError(null);
if (fromQr && initialTenantCode && initialBranchId != null) {
setLoading(true);
try {
const code = initialTenantCode.trim().toUpperCase();
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
const branch = branchData.find((item) => item.id === initialBranchId);
if (!branch) {
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
return;
}
const branchServices = await getBranchServices(code, branch.id);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setSelectedBranch(branch);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not reload services. Try scanning the QR again.");
setStep("tenant");
} finally {
setLoading(false);
}
return;
}
if (selectedBranch && tenant) {
setLoading(true);
try {
const branchServices = await getBranchServices(
tenant.code,
selectedBranch.id
);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not load services for this branch.");
setStep("branch");
} finally {
setLoading(false);
}
return;
}
setStep(tenant ? "branch" : "tenant");
}
function startOver() {
setStep("tenant");
setTenantCode("");
setTenant(null);
setBranches([]);
setSelectedBranch(null);
@@ -112,18 +277,21 @@ export default function CustomerTicketFlow() {
setError(null);
}
const welcomeCopy = fromQr
? "Pilih perkhidmatan untuk mendapatkan nombor."
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12">
<header className="space-y-2">
<p className="text-sm tracking-wide text-zinc-500 uppercase">
Queue Management
Sistem Nombor Giliran
</p>
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900">
{tenant?.name ?? "Get a ticket"}
{tenant?.name ?? "Dapatkan tiket"}
</h1>
<p className="text-base text-zinc-600">
{tenant?.welcomeMessage ??
"Enter your company code, pick a branch and service, then take a number."}
{tenant?.welcomeMessage ?? welcomeCopy}
</p>
</header>
@@ -133,27 +301,31 @@ export default function CustomerTicketFlow() {
</p>
) : null}
{fromQr && loading && step === "service" && !selectedBranch ? (
<p className="text-sm text-zinc-600">Loading perkhidmatan branch</p>
) : null}
{step === "tenant" ? (
<form onSubmit={handleTenantSubmit} className="space-y-4">
<label className="block space-y-2">
<span className="text-sm font-medium text-zinc-700">
Company code
Kod syarikat
</span>
<input
value={tenantCode}
onChange={(event) => setTenantCode(event.target.value)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 outline-none focus:border-zinc-900"
placeholder="e.g. DFLT"
placeholder="Kod syarikat"
autoComplete="off"
disabled={loading}
/>
</label>
<button
type="submit"
disabled={loading}
disabled={loading || !tenantCode.trim()}
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white disabled:opacity-60"
>
{loading ? "Loading…" : "Continue"}
{loading ? "Loading…" : "Lanjutkan"}
</button>
</form>
) : null}
@@ -196,25 +368,35 @@ export default function CustomerTicketFlow() {
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-medium text-zinc-900">
Service at {selectedBranch.name}
Perkhidmatan di Cawangan {selectedBranch.name}
</h2>
<button
type="button"
onClick={() => {
setStep("branch");
setSelectedBranch(null);
setServices([]);
setError(null);
}}
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Back
</button>
{fromQr ? (
<Link
href="/"
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Masukkan kod perkhidmatan
</Link>
) : (
<button
type="button"
onClick={() => {
setStep("branch");
setSelectedBranch(null);
setServices([]);
setError(null);
}}
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Kembali
</button>
)}
</div>
{services.length === 0 ? (
<p className="text-sm text-zinc-600">
No services are assigned to this branch yet. Link services via a
branch group in the admin app, then try again.
Tiada perkhidmatan yang ditugaskan kepada branch ini. Hubungkan
perkhidmatan melalui grup branch dalam aplikasi admin, kemudian
cuba lagi.
</p>
) : (
<ul className="space-y-2">
@@ -228,7 +410,7 @@ export default function CustomerTicketFlow() {
>
<span>{service.name}</span>
<span className="text-sm text-zinc-500">
{loading ? "…" : "Get number"}
{loading ? "…" : "Dapatkan nombor"}
</span>
</button>
</li>
@@ -241,7 +423,7 @@ export default function CustomerTicketFlow() {
{step === "ticket" && ticket ? (
<section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center">
<p className="text-sm tracking-wide text-zinc-500 uppercase">
Your ticket number
Nombor tiket anda
</p>
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
{ticket.number}
@@ -255,7 +437,7 @@ export default function CustomerTicketFlow() {
onClick={resetFlow}
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white"
>
Get another ticket
Dapatkan tiket lain
</button>
</section>
) : null}
+6
View File
@@ -80,3 +80,9 @@ export function createTicket(input: {
export function getTicketById(ticketId: number | string) {
return request<Ticket>(`/api/v1/tickets/${ticketId}`);
}
export function getTicketsForDevice(deviceToken: string) {
return request<TicketResponse[]>(
`/api/v1/tickets/devices/${encodeURIComponent(deviceToken)}`
);
}
+67
View File
@@ -0,0 +1,67 @@
const DEFAULT_QR_SECRET = "qms-branch-qr-v1";
function getQrSecret() {
return process.env.QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
}
function toBase64Url(bytes: Uint8Array) {
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function fromBase64Url(token: string) {
const padded = token.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (padded.length % 4)) % 4;
const base64 = padded + "=".repeat(padLength);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function xorBytes(bytes: Uint8Array, secret: string) {
const key = new TextEncoder().encode(secret);
return bytes.map((byte, index) => byte ^ key[index % key.length]);
}
export type BranchQrPayload = {
tenantCode: string;
branchId: number;
};
export function encodeBranchQrToken(
tenantCode: string,
branchId: number,
secret = getQrSecret()
) {
const plain = `v1:${tenantCode.trim().toUpperCase()}:${Number(branchId)}`;
const plainBytes = new TextEncoder().encode(plain);
return toBase64Url(xorBytes(plainBytes, secret));
}
export function decodeBranchQrToken(
token: string,
secret = getQrSecret()
): BranchQrPayload | null {
try {
const plain = new TextDecoder().decode(
xorBytes(fromBase64Url(token), secret)
);
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
if (!match) return null;
return {
tenantCode: match[1],
branchId: Number(match[2]),
};
} catch {
return null;
}
}
+1 -2
View File
@@ -1,4 +1,3 @@
export const SERVER_URL =
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
export const SERVER_URL = process.env.NEXT_PUBLIC_API_URL;
export const DEVICE_TOKEN_KEY = "qms_device_token";
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
devIndicators: false,
};
export default nextConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -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"}
+14 -11
View File
@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBQMS Teller App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyKOPKB QMS-Teller App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
Binary file not shown.
+15 -3
View File
@@ -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">
+4
View File
@@ -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',
+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>
);
}
@@ -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;
}