Dev/v1.0 #1

Merged
ismailmasseran merged 2 commits from dev/v1.0 into main 2026-07-23 10:51:04 +08:00
106 changed files with 19492 additions and 401 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()));
}
@@ -10,7 +10,6 @@ import ba.unsa.etf.si.bbqms.ticket_service.api.TicketService;
import ba.unsa.etf.si.bbqms.ws.models.DisplayDto;
import ba.unsa.etf.si.bbqms.ws.models.ErrorResponseDto;
import ba.unsa.etf.si.bbqms.ws.models.ServiceDto;
import ba.unsa.etf.si.bbqms.ws.models.ServiceResponseDto;
import ba.unsa.etf.si.bbqms.ws.models.TicketDto;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -28,8 +27,8 @@ public class TellerStationController {
private final TicketService ticketService;
public TellerStationController(final StationService stationService,
final AuthService authService,
final TicketService ticketService) {
final AuthService authService,
final TicketService ticketService) {
this.stationService = stationService;
this.authService = authService;
this.ticketService = ticketService;
@@ -37,7 +36,7 @@ public class TellerStationController {
@GetMapping("/{tenantCode}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity getAll(@PathVariable final String tenantCode){
public ResponseEntity getAll(@PathVariable final String tenantCode) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
@@ -56,43 +55,45 @@ public class TellerStationController {
@GetMapping("/{tenantCode}/{stationId}/services")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity getServices(@PathVariable final String tenantCode,
@PathVariable final String stationId,
@RequestParam(defaultValue = "true") final boolean assigned) {
@PathVariable final String stationId,
@RequestParam(defaultValue = "true") final boolean assigned) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
final Set<Service> serviceSet = this.stationService.getServicesByAssigned(Long.parseLong(stationId),assigned);
final Set<ServiceResponseDto> serviceResponseDtoSet = serviceSet.stream()
.map(ServiceResponseDto::fromEntity)
final Set<Service> serviceSet = this.stationService.getServicesByAssigned(Long.parseLong(stationId),
assigned);
final Set<ServiceDto> serviceDtoSet = serviceSet.stream()
.map(ServiceDto::fromEntity)
.collect(Collectors.toSet());
return ResponseEntity.ok().body(serviceResponseDtoSet);
}
catch (final Exception e) {
return ResponseEntity.ok().body(serviceDtoSet);
} catch (final Exception e) {
return ResponseEntity.badRequest().body(new ErrorResponseDto(e.getMessage()));
}
}
@GetMapping("/{tenantCode}/{stationId}/services/assignable")
public ResponseEntity findAssignableServices(@PathVariable final String stationId,
@PathVariable final String tenantCode) {
@PathVariable final String tenantCode) {
return ResponseEntity.ok().body(
this.stationService.findAssignableServices(Long.parseLong(stationId))
);
this.stationService.findAssignableServices(Long.parseLong(stationId)).stream()
.map(ServiceDto::fromEntity)
.collect(Collectors.toList()));
}
@PutMapping("/{tenantCode}/{stationId}/services/{serviceId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity addTellerStationService(@PathVariable final String tenantCode,
@PathVariable final String stationId,
@PathVariable final String serviceId) {
@PathVariable final String stationId,
@PathVariable final String serviceId) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
final TellerStation updatedTellerStation = this.stationService.addTellerStationService(Long.parseLong(stationId), Long.parseLong(serviceId));
final TellerStation updatedTellerStation = this.stationService
.addTellerStationService(Long.parseLong(stationId), Long.parseLong(serviceId));
return ResponseEntity.ok().body(TellerStationResponseDto.fromEntity(updatedTellerStation));
} catch (Exception e) {
return ResponseEntity.badRequest().build();
@@ -102,14 +103,15 @@ public class TellerStationController {
@DeleteMapping("/{tenantCode}/{stationId}/services/{serviceId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity deleteTellerStationService(@PathVariable final String tenantCode,
@PathVariable final String stationId,
@PathVariable final String serviceId) {
@PathVariable final String stationId,
@PathVariable final String serviceId) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
final TellerStation updatedTellerStation = this.stationService.deleteTellerStationService(Long.parseLong(stationId), Long.parseLong(serviceId));
final TellerStation updatedTellerStation = this.stationService
.deleteTellerStationService(Long.parseLong(stationId), Long.parseLong(serviceId));
return ResponseEntity.ok().body(TellerStationResponseDto.fromEntity(updatedTellerStation));
} catch (Exception e) {
return ResponseEntity.badRequest().body(new ErrorResponseDto(e.getMessage()));
@@ -119,16 +121,17 @@ public class TellerStationController {
@PutMapping("/{tenantCode}/{stationId}/displays/{displayId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity addDisplayToStation(@PathVariable final String tenantCode,
@PathVariable final String stationId,
@PathVariable final String displayId) {
@PathVariable final String stationId,
@PathVariable final String displayId) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
final TellerStation updatedTellerStation = this.stationService.addTellerStationDisplay(Long.parseLong(stationId), Long.parseLong(displayId));
final TellerStation updatedTellerStation = this.stationService
.addTellerStationDisplay(Long.parseLong(stationId), Long.parseLong(displayId));
return ResponseEntity.ok().body(TellerStationResponseDto.fromEntity(updatedTellerStation));
} catch(final Exception exception) {
} catch (final Exception exception) {
return ResponseEntity.badRequest().build();
}
}
@@ -136,29 +139,29 @@ public class TellerStationController {
@DeleteMapping("/{tenantCode}/{stationId}/displays/{displayId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity removeDisplayFromStation(@PathVariable final String tenantCode,
@PathVariable final String stationId,
@PathVariable final String displayId) {
@PathVariable final String stationId,
@PathVariable final String displayId) {
if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build();
}
try {
final TellerStation updatedTellerStation = this.stationService.deleteTellerStationDisplay(Long.parseLong(stationId), Long.parseLong(displayId));
final TellerStation updatedTellerStation = this.stationService
.deleteTellerStationDisplay(Long.parseLong(stationId), Long.parseLong(displayId));
return ResponseEntity.ok().body(TellerStationResponseDto.fromEntity(updatedTellerStation));
} catch(final Exception exception) {
} catch (final Exception exception) {
return ResponseEntity.badRequest().build();
}
}
@GetMapping("/{tenantCode}/{branchId}")
public ResponseEntity getBranchStations(@PathVariable final String tenantCode,
@PathVariable final String branchId) {
try{
@PathVariable final String branchId) {
try {
return ResponseEntity.ok().body(
this.stationService.getAllByBranch(Long.parseLong(branchId)).stream()
.map(TellerStationResponseDto::fromEntity)
.toList()
);
.toList());
} catch (final Exception exception) {
return ResponseEntity.badRequest().build();
}
@@ -168,7 +171,8 @@ public class TellerStationController {
public ResponseEntity getTicketsForTellerStation(@PathVariable final long stationId) {
try {
final List<TicketDto> ticketDtos = this.ticketService.findWithStation(stationId).stream()
.filter(ticket -> ticket.getTellerStation() == null || ticket.getTellerStation().getId() == stationId)
.filter(ticket -> ticket.getTellerStation() == null
|| ticket.getTellerStation().getId() == stationId)
.map(TicketDto::fromEntity)
.toList();
@@ -178,15 +182,14 @@ public class TellerStationController {
}
}
public record TellerStationResponseDto(long id, String name, DisplayDto display, Set<ServiceResponseDto> services) {
public record TellerStationResponseDto(long id, String name, DisplayDto display, Set<ServiceDto> services) {
public static TellerStationResponseDto fromEntity(final TellerStation tellerStation) {
final Set<Service> serviceSet = tellerStation.getServices();
return new TellerStationResponseDto(
tellerStation.getId(),
tellerStation.getName(),
tellerStation.getDisplay() != null ? DisplayDto.fromEntity(tellerStation.getDisplay()) : null,
serviceSet.stream().map(ServiceResponseDto::fromEntity).collect(Collectors.toSet())
);
serviceSet.stream().map(ServiceDto::fromEntity).collect(Collectors.toSet()));
}
}
}
@@ -32,9 +32,9 @@ public class TenantController {
private final AuthService authService;
public TenantController(final TenantService tenantService,
final BranchService branchService,
final GroupService groupService,
final AuthService authService) {
final BranchService branchService,
final GroupService groupService,
final AuthService authService) {
this.tenantService = tenantService;
this.branchService = branchService;
this.groupService = groupService;
@@ -57,7 +57,8 @@ public class TenantController {
@PutMapping("/{code}")
@PreAuthorize("isAuthenticated()")
public ResponseEntity updateTenant(@PathVariable final String code, @RequestBody final TenantDto request) throws Exception {
public ResponseEntity updateTenant(@PathVariable final String code, @RequestBody final TenantDto request)
throws Exception {
if (!this.authService.canChangeTenant(code)) {
return ResponseEntity.notFound().build();
}
@@ -71,7 +72,8 @@ public class TenantController {
@PostMapping("/{code}/services")
@PreAuthorize("hasAnyRole('ROLE_BRANCH_ADMIN', 'ROLE_SUPER_ADMIN')")
public ResponseEntity addService(@PathVariable(name = "code") final String code, @RequestBody final ServiceRequestDto serviceRequestDto) {
public ResponseEntity addService(@PathVariable(name = "code") final String code,
@RequestBody final ServiceRequestDto serviceRequestDto) {
if (!this.authService.canChangeTenant(code)) {
return ResponseEntity.notFound().build();
}
@@ -92,7 +94,8 @@ public class TenantController {
}
@GetMapping("/{code}/services/group/{groupId}")
public ResponseEntity listGroupAssignableServices(@PathVariable final String code, @PathVariable final String groupId) {
public ResponseEntity listGroupAssignableServices(@PathVariable final String code,
@PathVariable final String groupId) {
final List<Service> possibleServices = this.tenantService.getAllServicesByTenant(code);
final List<Service> assignedServices = this.groupService.get(Long.parseLong(groupId))
.getServices().stream().toList();
@@ -105,8 +108,8 @@ public class TenantController {
@PutMapping("/{code}/services/{id}")
@PreAuthorize("hasAnyRole('ROLE_BRANCH_ADMIN', 'ROLE_SUPER_ADMIN')")
public ResponseEntity updateService(@PathVariable(name = "code") final String code,
@PathVariable(name = "id") final Long id,
@RequestBody final ServiceRequestDto request) {
@PathVariable(name = "id") final Long id,
@RequestBody final ServiceRequestDto request) {
if (!this.authService.canChangeTenant(code)) {
return ResponseEntity.notFound().build();
}
@@ -120,7 +123,8 @@ public class TenantController {
@DeleteMapping("/{code}/services/{id}")
@PreAuthorize("hasAnyRole('ROLE_BRANCH_ADMIN', 'ROLE_SUPER_ADMIN')")
public ResponseEntity deleteService(@PathVariable(name = "code") final String code, @PathVariable(name = "id") final Long id) {
public ResponseEntity deleteService(@PathVariable(name = "code") final String code,
@PathVariable(name = "id") final Long id) {
if (!this.authService.canChangeTenant(code)) {
return ResponseEntity.notFound().build();
}
@@ -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"}
+74 -62
View File
@@ -19,27 +19,29 @@ import ManageBranches from './pages/ManageBranchesScreen/ManageBranchesScreen';
import ManageGroups from './pages/ManageGroupsScreen/ManageGroupsScreen';
import ManageStations from './pages/ManageStationScreen/ManageStationScreen';
import ManageDisplays from './pages/ManageDisplays/ManageDisplays';
import ManageAdsScreen from './pages/ManageAdsScreen/ManageAdsScreen';
import ManageUsers from './pages/UserManagingScreen/UserManagingScreen';
import ViewQueues from './pages/ViewBranchQueues/ViewBranchQueues';
import { ROLES } from './constants.js';
import { clearSession, getToken, getUserData } from './utils/session.js';
export default function App() {
const [user, setUser] = useState();
/*
Kada se logiramo, ako vec postoji token u localStorage, provjerimo da li je validan (nije istekao)
Ako je validan, ulogujemo usera, ako nije ocistimo storage od starih podataka
Kada se logiramo, ako vec postoji token u cookie-u, provjerimo da li je validan (nije istekao)
Ako je validan, ulogujemo usera, ako nije ocistimo session
*/
useEffect(() => {
const token = localStorage.getItem('token');
const token = getToken();
if (token) {
const url = `${ SERVER_URL }/api/v1/auth`;
const url = `${SERVER_URL}/api/v1/auth`;
fetchData(url, 'GET')
.then(({ data, success }) => {
.then(({ success }) => {
if (success) {
setUser(JSON.parse(localStorage.getItem('userData')));
setUser(getUserData());
} else {
localStorage.removeItem('token');
localStorage.removeItem('userData');
clearSession();
setUser(null);
}
});
}
@@ -47,152 +49,162 @@ export default function App() {
return (
<>
<UserContext.Provider value={ { user, setUser } }>
<UserContext.Provider value={{ user, setUser }}>
<Header />
<Routes>
<Route exact path="/:tenantCode/manage/displays" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageDisplays />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/manage/ads" element={
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageAdsScreen />
</AuthGuard>} />
<Route exact path="/:tenantCode/manage/stations" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageStations />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/manage/groups" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageGroups />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/manage/branches" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageBranches />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/companydetails"
element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<CompanyInfoUpdate />
</AuthGuard>
}
element={
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<CompanyInfoUpdate />
</AuthGuard>
}
/>
<Route exact path="/:tenantCode/manage/services" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageServices />
</AuthGuard> }
</AuthGuard>}
/>
<Route exact path="/:tenantCode/manage/users" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ManageUsers />
</AuthGuard>
} />
<Route exact path="/login" element={ <LoginScreen /> } />
<Route exact path="/login" element={<LoginScreen />} />
<Route exact path="/:tenantCode/manage/admins" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN]}>
<ManageAdmins />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/profile" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<AdminProfile />
</AuthGuard> } />
<Route exact path="/" element={ <LoginScreen /> } />
</AuthGuard>} />
<Route exact path="/" element={<LoginScreen />} />
<Route exact path="/loginauth"
element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<LoginAuth />
</AuthGuard>
}
element={
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<LoginAuth />
</AuthGuard>
}
/>
<Route exact path="/:tenantCode/queues" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<ViewQueues />
</AuthGuard> } />
</AuthGuard>} />
<Route exact path="/:tenantCode/home" element={
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<HomePage></HomePage>
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
<CanAccess roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
<>
{ user && (
{user && (
<>
<HomePageCard
title="Manage displays"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/displays` }
url={`/${user.tenantCode}/manage/displays`}
/>
<HomePageCard
title="Manage advertisements"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={`/${user.tenantCode}/manage/ads`}
/>
<HomePageCard
title="Manage groups"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/groups` }
url={`/${user.tenantCode}/manage/groups`}
/>
<HomePageCard
title="Manage branches"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/branches` }
url={`/${user.tenantCode}/manage/branches`}
/>
<HomePageCard
title="Manage services"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/services` }
url={`/${user.tenantCode}/manage/services`}
/>
<HomePageCard
title="Manage teller stations"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/stations` }
url={`/${user.tenantCode}/manage/stations`}
/>
<HomePageCard
title="Manage company details"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/companydetails` }
url={`/${user.tenantCode}/companydetails`}
/>
<HomePageCard
title="View queues"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/queues` }
url={`/${user.tenantCode}/queues`}
/>
</>
) }
)}
</>
</CanAccess>
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN] }>
<CanAccess roles={[ROLES.ROLE_SUPER_ADMIN]}>
<>
{ user && (
{user && (
<>
<HomePageCard title="Manage administrators"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/admins` }></HomePageCard>
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={`/${user.tenantCode}/manage/admins`}></HomePageCard>
<HomePageCard
title="Manage users"
title="Manage Teller"
backgroundColor="var(--light-blue)"
buttonColor="var(--dark-blue)"
url={ `/${ user.tenantCode }/manage/users` }
url={`/${user.tenantCode}/manage/users`}
/>
</>
) }
)}
</>
</CanAccess>
<CanAccess roles={ [ROLES.ROLE_BRANCH_ADMIN] }>
{ user && (
<CanAccess roles={[ROLES.ROLE_BRANCH_ADMIN]}>
{user && (
<HomePageCard
title="Manage users"
backgroundColor="var(--dark-blue)"
buttonColor="var(--light-blue)"
url={ `/${ user.tenantCode }/manage/users` }
url={`/${user.tenantCode}/manage/users`}
/>
) }
)}
</CanAccess>
</AuthGuard>
} />
<Route path="*" element={ <NotFound /> } />
<Route path="*" element={<NotFound />} />
</Routes>
</UserContext.Provider>
</>
@@ -1,12 +1,12 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getUserData } from '../../utils/session.js';
export default function AuthGuard({ children, roles }) {
const navigate = useNavigate();
useEffect(() => {
const storedUserData = localStorage.getItem('userData');
const user = storedUserData ? JSON.parse(storedUserData) : null;
const user = getUserData();
if (!user) {
navigate('/login');
@@ -23,7 +23,7 @@ export default function AuthGuard({ children, roles }) {
return (
<>
{ children }
{children}
</>
);
@@ -2,20 +2,19 @@ import { useContext } from 'react';
import { Button } from 'react-bootstrap';
import { useNavigate } from 'react-router-dom';
import { lastPathPart } from '../../utils/StringUtils.js';
import profileImage from '../../../assets/profile-user.png'
import './Header.css';
import { UserContext } from '../../context/UserContext.jsx';
import { clearSession } from '../../utils/session.js';
export default function Header() {
const navigate = useNavigate();
const { user, setUser } = useContext(UserContext);
function handleLogout() {
localStorage.removeItem('userData');
localStorage.removeItem('token');
clearSession();
setUser(null);
navigate('/');
navigate('/login');
}
const path = window.location.pathname;
@@ -35,28 +34,31 @@ export default function Header() {
return (
<>
<header className="main-header">
<h2 className="header-logo" onClick={ goHome }>BBQMS</h2>
<h2 className="header-logo" onClick={goHome}>
MyKOPKB QMS-Admin
</h2>
<div className="header-logout">
{ !!user && (
<button className="header-logout-btn" onClick={ handleLogout }>
{!!user && (
<button
className="header-logout-btn"
onClick={handleLogout}
>
Logout
</button>
) }
</div>
<div className="header-profile" onClick={ () => navigate('/profile') }>
<img src={ profileImage } className="header-profile-png" alt="Profile image" />
)}
</div>
</header>
{ showBackButton && (
<Button variant="secondary"
className="mt-2 px-4"
onClick={ goHome }>
{showBackButton && (
<Button
variant="secondary"
className="mt-2 px-4"
onClick={goHome}
>
Back
</Button>
) }
)}
</>
);
}
}
@@ -4,6 +4,7 @@ import { SERVER_URL } from "../../constants.js";
import { fetchData } from '../../fetching/Fetch.js';
import { UserContext } from '../../context/UserContext.jsx';
import { useNavigate } from "react-router-dom";
import { getUserData, setSession } from '../../utils/session.js';
function doSubmit(submittedValues) {
console.log(`Submitted: ${submittedValues.join("")}`);
@@ -126,10 +127,9 @@ export default function LoginAuth() {
dispatch({ type: "VERIFY" });
try {
const storedUserData = localStorage.getItem('userData');
const userData = storedUserData ? JSON.parse(storedUserData) : null;
const userData = getUserData();
if (!userData) {
throw new Error("Email not found in localStorage");
throw new Error("Email not found in session");
}
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
@@ -139,7 +139,11 @@ export default function LoginAuth() {
});
if (success) {
localStorage.setItem('token', data.token);
setSession({
token: data.token,
userData: data.userData,
isTfa: true,
});
setUser(data.userData);
navigate(`/${ data.userData.tenantCode }/home`);
} else {
@@ -5,6 +5,7 @@ import "./LoginForm.css";
import LoginAuth from "../LoginAuth/LoginAuth";
import { Route, Routes, useNavigate, Link } from "react-router-dom";
import { SERVER_URL } from "../../constants.js";
import { setSession } from "../../utils/session.js";
const LoginForm = () => {
const [username, setUsername] = useState("");
@@ -28,7 +29,11 @@ const LoginForm = () => {
return;
}
const data = await response.json();
localStorage.setItem('userData', JSON.stringify(data));
setSession({
userData: data.userData ?? data,
token: data.token,
isTfa: !data.token,
});
navigate('/');
}
@@ -67,9 +72,12 @@ const LoginForm = () => {
const data = await response.json();
localStorage.setItem('userData', JSON.stringify(data));
if (response.ok) {
setSession({
userData: data.userData ?? data,
token: data.token,
isTfa: !data.token,
});
setIsSubmitted(true);
navigate('/');
} else if (response.status === 403) {
+3
View File
@@ -1,5 +1,8 @@
export const SERVER_URL = 'http://localhost:8080';
export const CUSTOMER_APP_URL =
import.meta.env.VITE_CUSTOMER_APP_URL ?? 'http://localhost:3000';
export const ROLES = {
ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
+5 -3
View File
@@ -1,10 +1,12 @@
/*
Koristiti ovu funkciju za fetchanje u buducnosti kad god je to moguce.
*/
import { getToken, setToken } from '../utils/session.js';
export async function fetchData(url, method, body) {
const headers = new Headers();
const token = localStorage.getItem('token');
const token = getToken();
if (token) {
headers.append('Authorization', `Bearer ${ token }`);
}
@@ -27,9 +29,9 @@ export async function fetchData(url, method, body) {
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
const newToken = res.headers.get('Auth-Token');
if (newToken) {
localStorage.setItem('token', newToken);
setToken(newToken);
}
}
return { data: data, success: res.ok };
}
}
@@ -0,0 +1,35 @@
import { getToken, setToken } from '../utils/session.js';
/**
* Multipart upload helper. Do not set Content-Type manually — the browser
* must add the multipart boundary.
*/
export async function uploadFormData(url, formData, method = 'POST') {
const headers = new Headers();
const token = getToken();
if (token) {
headers.append('Authorization', `Bearer ${token}`);
}
const res = await fetch(url, {
method,
headers,
body: formData,
});
let data = null;
try {
data = await res.json();
} catch {
data = null;
}
if (res.ok) {
const newToken = res.headers.get('Auth-Token');
if (newToken) {
setToken(newToken);
}
}
return { data, success: res.ok };
}
@@ -4,6 +4,7 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import { SERVER_URL } from '../../constants.js';
import { UserContext } from '../../context/UserContext.jsx';
import { useNavigate, useParams } from "react-router-dom";
import { getToken } from '../../utils/session.js';
const styles = {
primaryButton: {
@@ -30,22 +31,22 @@ const AdminManageScreen = () => {
const [adminEmail, setAdminEmail] = useState('');
const [adminPassword, setAdminPassword] = useState('');
const [selectedAdminIndex, setSelectedAdminIndex] = useState(null);
const [token, setToken] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
if (getToken()) {
fetchAdmins();
}
}, []);
useEffect(() => {
if (token) {
fetchAdmins();
}
}, [token]);
const authHeaders = () => {
const token = getToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
const fetchAdmins = async () => {
try {
@@ -54,10 +55,7 @@ const AdminManageScreen = () => {
});
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: requestBody
});
@@ -87,10 +85,7 @@ const AdminManageScreen = () => {
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: JSON.stringify(requestBody)
});
@@ -122,10 +117,7 @@ const AdminManageScreen = () => {
};
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${admins[selectedAdminIndex].id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: JSON.stringify(updatedAdmin),
});
if (response.ok) {
@@ -146,10 +138,7 @@ const AdminManageScreen = () => {
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': token
}
headers: authHeaders(),
});
if (response.ok) {
const updatedAdmins = admins.filter(admin => admin.id !== userId);
@@ -1,59 +1,57 @@
import { useState, useEffect } from 'react';
import { fetchData } from '../../fetching/Fetch.js';
import { SERVER_URL } from '../../constants';
import { getIsTfa, getUserData, setIsTfa } from '../../utils/session.js';
import "./AdminProfile.css"
export default function AdminProfile(){
export default function AdminProfile() {
const [isChecked, setIsChecked] = useState(false);
const [isQRCodeEnabled, setIsQRCodeEnabled] = useState(false);
const [qrCodeSrc, setQrCodeSrc] = useState('');
const [userData, setUserData] = useState('');
useEffect(() => {
const storedUserData = localStorage.getItem('userData');
setUserData(JSON.parse(storedUserData));
const storedIsTfa = localStorage.getItem('isTfa');
let isTfa = JSON.parse(storedIsTfa);
setUserData(getUserData());
const isTfa = getIsTfa();
setIsChecked(isTfa);
setIsQRCodeEnabled(isTfa);
}, []);
const handleSaveChanges = async () =>{
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
const handleSaveChanges = async () => {
const url = `${SERVER_URL}/api/v1/auth/tfa`;
const { data, success } = await fetchData(url, 'PUT', {
isTfa: isChecked
});
localStorage.setItem('isTfa', isChecked);
setIsTfa(isChecked);
setIsQRCodeEnabled(isChecked);
if(!isChecked){
if (!isChecked) {
setQrCodeSrc('');
}
if(success){
if (success) {
let message = 'Success: Your changes have been successfully submitted.';
if(isChecked){
if (isChecked) {
message = message + '\nPlease scan QR code.';
}
alert(message);
}else{
} else {
alert('An error occurred. Please try again.');
}
}
const handleCheckBoxChange = () =>{
const handleCheckBoxChange = () => {
setIsChecked(!isChecked);
}
const handleGenerateQRCode = () =>{
if(isQRCodeEnabled){
const url = `${ SERVER_URL }/api/v1/auth/tfa?email=${userData.email}`;
const handleGenerateQRCode = () => {
if (isQRCodeEnabled) {
const url = `${SERVER_URL}/api/v1/auth/tfa?email=${userData.email}`;
fetchData(url, 'GET')
.then(({ data, success }) => {
if (success) {
setQrCodeSrc(data.qrCode);
}
});
.then(({ data, success }) => {
if (success) {
setQrCodeSrc(data.qrCode);
}
});
}
}
@@ -61,17 +59,17 @@ export default function AdminProfile(){
<div id="account-settings">
<h1>Account settings</h1>
<div id="check-box">
<form>
{/* <form>
<label htmlFor="2fa">Use two-factor authentication:</label>
<input type="checkbox" id="2fa" name="2fa" checked={isChecked} onChange={handleCheckBoxChange}></input>
</form>
</form> */}
</div>
<div id="QR-code">
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode}/>
{/* <div id="QR-code">
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode} />
<img src={qrCodeSrc}></img>
</div>
</div> */}
<div>
<input type="submit" value="Save changes" onClick={handleSaveChanges}/>
<input type="submit" value="Save changes" onClick={handleSaveChanges} />
</div>
</div>
);
@@ -1,13 +1,22 @@
import React, { useState } from 'react';
import React, { useContext, useState } from 'react';
import validator from 'validator';
import './LoginScreen.css';
import { useNavigate } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
import { ROLES, SERVER_URL } from '../../constants.js';
import { fetchData } from '../../fetching/Fetch.js';
import { UserContext } from '../../context/UserContext.jsx';
import { clearSession, setSession } from '../../utils/session.js';
const ADMIN_ROLES = [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN];
function hasAdminAccess(userData) {
const roles = Array.isArray(userData?.roles) ? userData.roles : [];
return roles.some((role) => ADMIN_ROLES.includes(role));
}
export default function LoginScreen() {
const navigate = useNavigate();
const { setUser } = useContext(UserContext);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -38,26 +47,42 @@ export default function LoginScreen() {
password: password
});
if (success) {
if (data.userData == undefined) {
localStorage.setItem('userData', JSON.stringify(data));
} else {
localStorage.setItem('userData', JSON.stringify(data.userData));
localStorage.setItem('token', data.token);
setUser(data.userData);
}
setIsSubmitted(true);
if (data.token) {
localStorage.setItem('isTfa', false);
navigate(`/${data.userData.tenantCode}/home`);
} else {
localStorage.setItem('isTfa', true);
navigate('/loginauth');
}
} else {
if (!success || !data) {
setError('Your credentials are incorrect.');
return;
}
const userData = data.userData ?? data;
const token = data.token;
if (!hasAdminAccess(userData)) {
clearSession();
setUser(null);
setError(
'This account is a regular user (ROLE_USER) and cannot access the admin app. Create an admin via Manage administrators.'
);
return;
}
if (!token) {
setSession({ userData, isTfa: true });
setUser(userData);
navigate('/loginauth', { replace: true });
return;
}
if (!userData?.tenantCode) {
setError('Login succeeded but tenant information is missing.');
return;
}
setSession({
userData,
token,
isTfa: false,
});
setUser(userData);
navigate(`/${userData.tenantCode}/home`, { replace: true });
} catch (error) {
console.error('Error:', error);
setError('An error occurred. Please try again.');
@@ -73,11 +98,6 @@ export default function LoginScreen() {
setPassword(event.target.value);
setError('');
};
/*
if (isSubmitted) {
//navigate('/loginAuth');
navigate('/companydetails');
}*/
return (
<div id="login-form">
@@ -92,9 +112,7 @@ export default function LoginScreen() {
value={username}
onChange={handleUsernameChange}
/>
{error && (error.includes('Username') || error.includes('Invalid')) &&
<p className="error">{error}</p>}
{error && (error.includes('credentials')) && <p className="error">{error}</p>}
{error && !error.includes('Password') && <p className="error">{error}</p>}
</div>
<div className="form-group">
<label htmlFor="password">Password:</label>
@@ -116,4 +134,4 @@ export default function LoginScreen() {
</form>
</div>
);
};
};
@@ -0,0 +1,62 @@
.ad-tv-preview {
text-align: left;
border-radius: 0.75rem;
border: 1px solid #334155;
background: #0f172a;
color: #f8fafc;
padding: 0.85rem;
}
.ad-tv-preview__chrome {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.65rem;
}
.ad-tv-preview__label {
font-size: 0.75rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #94a3b8;
}
.ad-tv-preview__ratio {
font-size: 0.8rem;
color: #cbd5e1;
}
.ad-tv-preview__stage {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 0.5rem;
overflow: hidden;
background: #1e293b;
display: flex;
align-items: center;
justify-content: center;
}
.ad-tv-preview__media {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: #0f172a;
}
.ad-tv-preview__empty {
margin: 0;
padding: 1rem;
color: #94a3b8;
font-size: 0.95rem;
text-align: center;
}
.ad-tv-preview__caption {
margin: 0.65rem 0 0;
text-align: center;
font-size: 0.95rem;
color: #cbd5e1;
}
@@ -0,0 +1,597 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Button, Form, Modal, Table } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { fetchData } from '../../fetching/Fetch.js';
import { uploadFormData } from '../../fetching/uploadFormData.js';
import { SERVER_URL } from '../../constants.js';
import './ManageAdsScreen.css';
const styles = {
primaryButton: {
backgroundColor: '#548CA8',
borderColor: '#548CA8',
},
infoButton: {
backgroundColor: '#548CA8',
color: 'white',
borderColor: '#548CA8',
},
modalHeader: {
backgroundColor: '#334257',
color: 'white',
},
};
const ACCEPTED_TYPES = 'image/jpeg,image/png,image/webp,image/gif,video/mp4,video/webm';
/** Matches branch TV ad box (see BranchDisplayPage `.branch-display__ad-stage`). */
const AD_DISPLAY_SPEC = {
aspectRatio: '16:10',
recommendedSize: '1920 × 1200 px',
minSize: '1280 × 800 px',
};
function AdDisplayGuidelines() {
return (
<div
className="text-start small rounded border p-3 mb-3"
style={{ backgroundColor: '#f8fafc', borderColor: '#cbd5e1' }}
>
<strong>TV display size guide</strong>
<ul className="mb-0 mt-2 ps-3">
<li>
Aspect ratio: <strong>{AD_DISPLAY_SPEC.aspectRatio}</strong> (width : height)
</li>
<li>
Recommended resolution: <strong>{AD_DISPLAY_SPEC.recommendedSize}</strong>
</li>
<li>
Minimum for clear TV: <strong>{AD_DISPLAY_SPEC.minSize}</strong>
</li>
<li>
Use landscape media that fills the frame. Other ratios will show empty bars
or look cropped on the branch display.
</li>
</ul>
</div>
);
}
function mediaUrlFromAd(ad) {
if (!ad?.mediaUrl) return null;
return ad.mediaUrl.startsWith('http')
? ad.mediaUrl
: `${SERVER_URL}${ad.mediaUrl}`;
}
function isVideoFile(file) {
return Boolean(file?.type?.startsWith('video/'));
}
/**
* Mimics the branch TV advertisement panel (16:10 stage).
*/
function AdTvPreview({ src, isVideo, title, emptyLabel = 'Select a file to preview' }) {
return (
<div className="ad-tv-preview">
<div className="ad-tv-preview__chrome">
<span className="ad-tv-preview__label">Branch TV preview</span>
<span className="ad-tv-preview__ratio">{AD_DISPLAY_SPEC.aspectRatio}</span>
</div>
<div className="ad-tv-preview__stage">
{!src ? (
<p className="ad-tv-preview__empty">{emptyLabel}</p>
) : isVideo ? (
<video
key={src}
className="ad-tv-preview__media"
src={src}
controls
muted
playsInline
/>
) : (
<img
key={src}
className="ad-tv-preview__media"
src={src}
alt={title || 'Advertisement preview'}
/>
)}
</div>
{title ? <p className="ad-tv-preview__caption">{title}</p> : null}
</div>
);
}
export default function ManageAdsScreen() {
const { tenantCode } = useParams();
const [ads, setAds] = useState([]);
const [errorMessage, setErrorMessage] = useState('');
const [loading, setLoading] = useState(false);
const [showUpload, setShowUpload] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [selectedAd, setSelectedAd] = useState(null);
const [file, setFile] = useState(null);
const [title, setTitle] = useState('');
const [durationSeconds, setDurationSeconds] = useState(10);
const [sortOrder, setSortOrder] = useState('');
const [active, setActive] = useState(true);
const url = `${SERVER_URL}/api/v1/ads/${encodeURIComponent(tenantCode)}`;
const uploadObjectUrl = useMemo(() => {
if (!file) return null;
return URL.createObjectURL(file);
}, [file]);
useEffect(() => {
return () => {
if (uploadObjectUrl) {
URL.revokeObjectURL(uploadObjectUrl);
}
};
}, [uploadObjectUrl]);
const loadAds = useCallback(async () => {
try {
const response = await fetchData(url, 'GET');
if (!response.success) {
setErrorMessage('Failed to load advertisements.');
return;
}
setAds(Array.isArray(response.data) ? response.data : []);
} catch (error) {
console.error(error);
setErrorMessage('Failed to load advertisements.');
}
}, [url]);
useEffect(() => {
loadAds();
}, [loadAds]);
function resetUploadForm() {
setFile(null);
setTitle('');
setDurationSeconds(10);
setSortOrder('');
setActive(true);
}
function openEdit(ad) {
setSelectedAd(ad);
setTitle(ad.title ?? '');
setDurationSeconds(ad.durationSeconds ?? 10);
setSortOrder(String(ad.sortOrder ?? 0));
setActive(Boolean(ad.active));
setShowEdit(true);
}
function openPreview(ad) {
setSelectedAd(ad);
setShowPreview(true);
}
async function handleUpload(event) {
event.preventDefault();
if (!file) {
setErrorMessage('Choose an image or video file to upload.');
return;
}
setLoading(true);
try {
const formData = new FormData();
formData.append('file', file);
if (title.trim()) {
formData.append('title', title.trim());
}
formData.append('durationSeconds', String(durationSeconds || 10));
formData.append('active', String(active));
if (sortOrder !== '' && !Number.isNaN(Number(sortOrder))) {
formData.append('sortOrder', String(Number(sortOrder)));
}
const response = await uploadFormData(url, formData, 'POST');
if (!response.success) {
setErrorMessage(response.data?.message || 'Failed to upload advertisement.');
return;
}
setShowUpload(false);
resetUploadForm();
await loadAds();
} catch (error) {
console.error(error);
setErrorMessage('Failed to upload advertisement.');
} finally {
setLoading(false);
}
}
async function handleEdit(event) {
event.preventDefault();
if (!selectedAd) return;
setLoading(true);
try {
const response = await fetchData(`${url}/${selectedAd.id}`, 'PUT', {
title: title.trim() || null,
durationSeconds: Number(durationSeconds) || 10,
sortOrder: sortOrder === '' ? null : Number(sortOrder),
active,
});
if (!response.success) {
setErrorMessage(response.data?.message || 'Failed to update advertisement.');
return;
}
setShowEdit(false);
setSelectedAd(null);
await loadAds();
} catch (error) {
console.error(error);
setErrorMessage('Failed to update advertisement.');
} finally {
setLoading(false);
}
}
async function handleDelete() {
if (!selectedAd) return;
setLoading(true);
try {
const response = await fetchData(`${url}/${selectedAd.id}`, 'DELETE');
if (!response.success) {
setErrorMessage(response.data?.message || 'Failed to delete advertisement.');
return;
}
setShowDelete(false);
setSelectedAd(null);
await loadAds();
} catch (error) {
console.error(error);
setErrorMessage('Failed to delete advertisement.');
} finally {
setLoading(false);
}
}
return (
<div className="text-center">
<h2>Manage Advertisements</h2>
<p className="text-muted mb-2">
Ads are shared across all branches for this company.
</p>
<div className="mx-auto mb-3" style={{ maxWidth: 640 }}>
<AdDisplayGuidelines />
</div>
<Button
variant="primary"
style={styles.primaryButton}
className="mb-3"
onClick={() => {
resetUploadForm();
setShowUpload(true);
}}
>
Upload Ad
</Button>
<Table striped bordered hover>
<thead>
<tr>
<th>Preview</th>
<th>Title</th>
<th>Type</th>
<th>Order</th>
<th>Duration (s)</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{ads.length === 0 ? (
<tr>
<td colSpan={7} className="text-muted">
No advertisements yet.
</td>
</tr>
) : (
ads.map((ad) => (
<tr key={ad.id}>
<td style={{ width: 120 }}>
{ad.mediaType === 'IMAGE' ? (
<img
src={mediaUrlFromAd(ad)}
alt={ad.title || ad.fileName}
style={{
maxWidth: 100,
maxHeight: 60,
objectFit: 'cover',
cursor: 'pointer',
}}
onClick={() => openPreview(ad)}
/>
) : (
<Button
variant="link"
className="p-0"
onClick={() => openPreview(ad)}
>
Video
</Button>
)}
</td>
<td>{ad.title || ad.fileName}</td>
<td>{ad.mediaType}</td>
<td>{ad.sortOrder}</td>
<td>{ad.durationSeconds}</td>
<td>{ad.active ? 'Yes' : 'No'}</td>
<td>
<Button
variant="info"
style={styles.infoButton}
onClick={() => openPreview(ad)}
>
Preview
</Button>{' '}
<Button
variant="info"
style={styles.infoButton}
onClick={() => openEdit(ad)}
>
Edit
</Button>{' '}
<Button
variant="danger"
onClick={() => {
setSelectedAd(ad);
setShowDelete(true);
}}
>
Delete
</Button>
</td>
</tr>
))
)}
</tbody>
</Table>
<Modal
show={showUpload}
onHide={() => {
setShowUpload(false);
resetUploadForm();
}}
size="lg"
>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>UPLOAD ADVERTISEMENT</Modal.Title>
</Modal.Header>
<Form onSubmit={handleUpload}>
<Modal.Body>
<AdDisplayGuidelines />
<AdTvPreview
src={uploadObjectUrl}
isVideo={isVideoFile(file)}
title={title.trim() || file?.name}
/>
<Form.Group className="mb-3 mt-3">
<Form.Label>File (image or video)</Form.Label>
<Form.Control
type="file"
accept={ACCEPTED_TYPES}
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
required
/>
<Form.Text className="text-muted">
Best fit: {AD_DISPLAY_SPEC.aspectRatio},{' '}
{AD_DISPLAY_SPEC.recommendedSize}
</Form.Text>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Title</Form.Label>
<Form.Control
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder="Optional"
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Duration seconds (images)</Form.Label>
<Form.Control
type="number"
min={1}
value={durationSeconds}
onChange={(event) => setDurationSeconds(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Sort order</Form.Label>
<Form.Control
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
placeholder="Auto if empty"
/>
</Form.Group>
<Form.Check
type="switch"
id="upload-active"
label="Active"
checked={active}
onChange={(event) => setActive(event.target.checked)}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setShowUpload(false);
resetUploadForm();
}}
>
Close
</Button>
<Button
type="submit"
variant="primary"
style={styles.primaryButton}
disabled={loading}
>
{loading ? 'Uploading…' : 'Upload'}
</Button>
</Modal.Footer>
</Form>
</Modal>
<Modal show={showEdit} onHide={() => setShowEdit(false)} size="lg">
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>EDIT ADVERTISEMENT</Modal.Title>
</Modal.Header>
<Form onSubmit={handleEdit}>
<Modal.Body>
{selectedAd ? (
<AdTvPreview
src={mediaUrlFromAd(selectedAd)}
isVideo={selectedAd.mediaType === 'VIDEO'}
title={title.trim() || selectedAd.fileName}
/>
) : null}
<Form.Group className="mb-3 mt-3">
<Form.Label>Title</Form.Label>
<Form.Control
type="text"
value={title}
onChange={(event) => setTitle(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Duration seconds (images)</Form.Label>
<Form.Control
type="number"
min={1}
value={durationSeconds}
onChange={(event) => setDurationSeconds(event.target.value)}
/>
</Form.Group>
<Form.Group className="mb-3">
<Form.Label>Sort order</Form.Label>
<Form.Control
type="number"
value={sortOrder}
onChange={(event) => setSortOrder(event.target.value)}
/>
</Form.Group>
<Form.Check
type="switch"
id="edit-active"
label="Active"
checked={active}
onChange={(event) => setActive(event.target.checked)}
/>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowEdit(false)}>
Close
</Button>
<Button
type="submit"
variant="primary"
style={styles.primaryButton}
disabled={loading}
>
{loading ? 'Saving…' : 'Save'}
</Button>
</Modal.Footer>
</Form>
</Modal>
<Modal
show={showPreview}
onHide={() => {
setShowPreview(false);
setSelectedAd(null);
}}
size="lg"
centered
>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>TV DISPLAY PREVIEW</Modal.Title>
</Modal.Header>
<Modal.Body>
{selectedAd ? (
<AdTvPreview
src={mediaUrlFromAd(selectedAd)}
isVideo={selectedAd.mediaType === 'VIDEO'}
title={selectedAd.title || selectedAd.fileName}
/>
) : null}
<p className="text-muted small mt-3 mb-0 text-center">
This matches the advertisement panel on the branch waiting-area TV
({AD_DISPLAY_SPEC.aspectRatio}).
</p>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
setShowPreview(false);
setSelectedAd(null);
}}
>
Close
</Button>
</Modal.Footer>
</Modal>
<Modal show={showDelete} onHide={() => setShowDelete(false)}>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>CONFIRMATION</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
Delete advertisement{' '}
<strong>{selectedAd?.title || selectedAd?.fileName}</strong>?
</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setShowDelete(false)}>
Cancel
</Button>
<Button variant="danger" onClick={handleDelete} disabled={loading}>
{loading ? 'Deleting…' : 'Delete'}
</Button>
</Modal.Footer>
</Modal>
<Modal
show={errorMessage !== ''}
onHide={() => setErrorMessage('')}
backdrop="static"
keyboard={false}
>
<Modal.Header closeButton style={{ backgroundColor: '#dc3545', color: 'white' }}>
<Modal.Title>ERROR</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>{errorMessage}</p>
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={() => setErrorMessage('')}>
Close
</Button>
</Modal.Footer>
</Modal>
</div>
);
}
@@ -4,6 +4,11 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import { fetchData } from '../../fetching/Fetch.js';
import { useParams } from 'react-router-dom';
import { SERVER_URL } from '../../constants.js';
import {
downloadBranchQr,
generateBranchQrDataUrl,
getBranchTicketUrl,
} from '../../utils/branchQr.js';
const styles = {
primaryButton: {
@@ -30,6 +35,9 @@ const ManageBranchesScreen = () => {
const [newStationName, setNewStationName] = useState('');
const [deleteConfirmation, setDeleteConfirmation] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [qrModalBranch, setQrModalBranch] = useState(null);
const [qrDataUrl, setQrDataUrl] = useState('');
const [qrLoading, setQrLoading] = useState(false);
const { tenantCode } = useParams();
const url = `${SERVER_URL}/api/v1/branches/${tenantCode}`;
@@ -154,6 +162,42 @@ const ManageBranchesScreen = () => {
setSelectedBranchIndex(index);
};
const handleShowQr = async (branch) => {
setQrModalBranch(branch);
setQrDataUrl('');
setQrLoading(true);
try {
const dataUrl = await generateBranchQrDataUrl(tenantCode, branch.id);
setQrDataUrl(dataUrl);
} catch (error) {
console.error('Error:', error);
setQrModalBranch(null);
setErrorMessage('Failed to generate branch QR code.');
} finally {
setQrLoading(false);
}
};
const handleDownloadQr = async () => {
if (!qrModalBranch) return;
try {
await downloadBranchQr({
tenantCode,
branchId: qrModalBranch.id,
branchName: qrModalBranch.name,
});
} catch (error) {
console.error('Error:', error);
setErrorMessage('Failed to download branch QR code.');
}
};
const handleCloseQrModal = () => {
setQrModalBranch(null);
setQrDataUrl('');
setQrLoading(false);
};
const confirmDeleteBranch = async () => {
const branchId = manageBranches[selectedBranchIndex].id;
const urlToDelete = `${url}/${branchId}`;
@@ -221,6 +265,7 @@ const ManageBranchesScreen = () => {
<td>{branch.tellerStations ? branch.tellerStations.map(station => station.name).join(', ') : '-'}</td>
<td>
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
<Button variant="info" style={styles.infoButton} onClick={() => handleShowQr(branch)}>QR</Button>{' '}
<Button variant="danger" onClick={() => handleDeleteBranch(index)}>Delete</Button>
</td>
</tr>
@@ -270,6 +315,46 @@ const ManageBranchesScreen = () => {
</Modal.Footer>
</Modal>
<Modal show={qrModalBranch !== null} onHide={handleCloseQrModal}>
<Modal.Header closeButton style={styles.modalHeader}>
<Modal.Title>
BRANCH QR{qrModalBranch ? `${qrModalBranch.name}` : ''}
</Modal.Title>
</Modal.Header>
<Modal.Body className="text-center">
{qrLoading ? (
<p>Generating QR</p>
) : qrDataUrl ? (
<>
<img
src={qrDataUrl}
alt={`QR code for ${qrModalBranch?.name}`}
style={{ width: 280, height: 280, maxWidth: '100%' }}
/>
<p className="mt-3 mb-0 text-break small text-muted">
{qrModalBranch
? getBranchTicketUrl(tenantCode, qrModalBranch.id)
: ''}
</p>
<p className="mt-2 mb-0 small">
Print this QR and place it at the branch. Customers scan it to choose a service.
</p>
</>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={handleCloseQrModal}>Close</Button>
<Button
variant="primary"
style={styles.primaryButton}
onClick={handleDownloadQr}
disabled={!qrDataUrl || qrLoading}
>
Download PNG
</Button>
</Modal.Footer>
</Modal>
<Modal show={deleteConfirmation} onHide={() => setDeleteConfirmation(false)}>
<Modal.Header closeButton style={{ backgroundColor: '#334257', color: 'white' }}>
<Modal.Title>CONFIRMATION</Modal.Title>
@@ -2,8 +2,8 @@ import React, { useState, useEffect } from 'react';
import { Button, Table, Modal, Form } from 'react-bootstrap';
import 'bootstrap/dist/css/bootstrap.min.css';
import { SERVER_URL } from '../../constants.js';
import { UserContext } from '../../context/UserContext.jsx';
import { useNavigate, useParams } from "react-router-dom";
import { useParams } from "react-router-dom";
import { getToken } from '../../utils/session.js';
const styles = {
primaryButton: {
@@ -29,34 +29,31 @@ const UserManageScreen = () => {
const [userEmail, setUserEmail] = useState('');
const [userPassword, setUserPassword] = useState('');
const [selectedUserIndex, setSelectedUserIndex] = useState(null);
const [token, setToken] = useState('');
const [emailError, setEmailError] = useState('');
const [passwordError, setPasswordError] = useState('');
useEffect(() => {
const storedToken = localStorage.getItem('token');
if (storedToken) {
setToken(storedToken);
if (getToken()) {
fetchUsers();
}
}, []);
useEffect(() => {
if (token) {
fetchUsers();
}
}, [token]);
const authHeaders = () => {
const token = getToken();
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
};
const fetchUsers = async () => {
try {
const requestBody = JSON.stringify({
roleName: 'ROLE_USER'
});
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: requestBody
});
@@ -84,12 +81,9 @@ const UserManageScreen = () => {
};
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user`, {
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: JSON.stringify(requestBody)
});
@@ -119,12 +113,9 @@ const UserManageScreen = () => {
const updatedUser = {
email: userEmail
};
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${users[selectedUserIndex].id}`, {
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user/${users[selectedUserIndex].id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': token
},
headers: authHeaders(),
body: JSON.stringify(updatedUser),
});
if (response.ok) {
@@ -144,12 +135,9 @@ const UserManageScreen = () => {
const handleDeleteUser = async (userId) => {
try {
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${userId}`, {
const response = await fetch(`${SERVER_URL}/api/v1/admin/${tenantCode}/user/${userId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': token
}
headers: authHeaders(),
});
if (response.ok) {
const updatedUsers = users.filter(user => user.id !== userId);
@@ -171,28 +159,32 @@ const UserManageScreen = () => {
return (
<div className="text-center">
<h2>Manage Users</h2>
<h2>Manage Teller</h2>
<p className="text-muted mb-3">
These accounts have ROLE_USER and cannot log into the admin app.
Use Manage administrators to create admin logins.
</p>
<Button variant="primary" style={styles.primaryButton} className="mb-3" onClick={() => { setShowModal(true); setSelectedUserIndex(null); }}>Add User</Button>
<Table striped bordered hover>
<thead>
<tr>
<th>ID</th>
<th>Email</th>
<th>Actions</th>
</tr>
<tr>
<th>ID</th>
<th>Email</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{users.map((user, index) => (
<tr key={index}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
<Button variant="danger" onClick={() => handleDeleteUser(user.id)}>Delete</Button>
</td>
</tr>
))}
{users.map((user, index) => (
<tr key={index}>
<td>{user.id}</td>
<td>{user.email}</td>
<td>
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
<Button variant="danger" onClick={() => handleDeleteUser(user.id)}>Delete</Button>
</td>
</tr>
))}
</tbody>
</Table>
+93
View File
@@ -0,0 +1,93 @@
import QRCode from 'qrcode';
import { CUSTOMER_APP_URL } from '../constants.js';
const DEFAULT_QR_SECRET = 'qms-branch-qr-v1';
function getQrSecret() {
return import.meta.env.VITE_QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
}
function toBase64Url(bytes) {
let binary = '';
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function fromBase64Url(token) {
const padded = token.replace(/-/g, '+').replace(/_/g, '/');
const padLength = (4 - (padded.length % 4)) % 4;
const base64 = padded + '='.repeat(padLength);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function xorBytes(bytes, secret) {
const key = new TextEncoder().encode(secret);
return bytes.map((byte, index) => byte ^ key[index % key.length]);
}
/** Opaque token so QR URLs don't expose tenant code / branch id. */
export function encodeBranchQrToken(tenantCode, branchId, secret = getQrSecret()) {
const plain = `v1:${String(tenantCode).trim().toUpperCase()}:${Number(branchId)}`;
const plainBytes = new TextEncoder().encode(plain);
return toBase64Url(xorBytes(plainBytes, secret));
}
export function decodeBranchQrToken(token, secret = getQrSecret()) {
try {
const plain = new TextDecoder().decode(xorBytes(fromBase64Url(token), secret));
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
if (!match) return null;
return {
tenantCode: match[1],
branchId: Number(match[2]),
};
} catch {
return null;
}
}
export function getBranchTicketUrl(tenantCode, branchId) {
const base = CUSTOMER_APP_URL.replace(/\/$/, '');
const token = encodeBranchQrToken(tenantCode, branchId);
return `${base}/q/${token}`;
}
export async function generateBranchQrDataUrl(tenantCode, branchId) {
return QRCode.toDataURL(getBranchTicketUrl(tenantCode, branchId), {
errorCorrectionLevel: 'M',
margin: 2,
width: 1024,
color: {
dark: '#000000',
light: '#ffffff',
},
});
}
function slugify(value) {
return String(value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'branch';
}
export function downloadDataUrl(dataUrl, filename) {
const link = document.createElement('a');
link.href = dataUrl;
link.download = filename;
link.click();
}
export async function downloadBranchQr({ tenantCode, branchId, branchName }) {
const dataUrl = await generateBranchQrDataUrl(tenantCode, branchId);
downloadDataUrl(dataUrl, `qr-${slugify(branchName)}-${branchId}.png`);
return dataUrl;
}
+124
View File
@@ -0,0 +1,124 @@
const TOKEN_KEY = 'token';
const USER_DATA_KEY = 'userData';
const IS_TFA_KEY = 'isTfa';
/** Matches backend jwt.token-validity-time (PT30M). */
const SESSION_MAX_AGE_SECONDS = 30 * 60;
function getCookie(name) {
const prefix = `${encodeURIComponent(name)}=`;
const parts = document.cookie ? document.cookie.split('; ') : [];
for (const part of parts) {
if (part.startsWith(prefix)) {
return decodeURIComponent(part.slice(prefix.length));
}
}
return null;
}
function setCookie(name, value, maxAgeSeconds = SESSION_MAX_AGE_SECONDS) {
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
document.cookie = [
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
`Path=/`,
`Max-Age=${maxAgeSeconds}`,
`SameSite=Lax`,
secure,
].join('; ');
}
function removeCookie(name) {
document.cookie = `${encodeURIComponent(name)}=; Path=/; Max-Age=0; SameSite=Lax`;
}
function migrateFromLocalStorage(key) {
const legacy = localStorage.getItem(key);
if (legacy == null) {
return null;
}
setCookie(key, legacy);
localStorage.removeItem(key);
return legacy;
}
export function getToken() {
return getCookie(TOKEN_KEY) ?? migrateFromLocalStorage(TOKEN_KEY);
}
export function setToken(token) {
if (token == null || token === '') {
removeCookie(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
return;
}
setCookie(TOKEN_KEY, token);
localStorage.removeItem(TOKEN_KEY);
}
export function getUserData() {
const raw = getCookie(USER_DATA_KEY) ?? migrateFromLocalStorage(USER_DATA_KEY);
if (!raw) {
return null;
}
try {
return JSON.parse(raw);
} catch {
removeCookie(USER_DATA_KEY);
return null;
}
}
export function setUserData(userData) {
if (userData == null) {
removeCookie(USER_DATA_KEY);
localStorage.removeItem(USER_DATA_KEY);
return;
}
setCookie(USER_DATA_KEY, JSON.stringify(userData));
localStorage.removeItem(USER_DATA_KEY);
}
export function getIsTfa() {
const raw = getCookie(IS_TFA_KEY) ?? migrateFromLocalStorage(IS_TFA_KEY);
if (raw == null) {
return false;
}
try {
return JSON.parse(raw);
} catch {
return raw === 'true';
}
}
export function setIsTfa(isTfa) {
setCookie(IS_TFA_KEY, JSON.stringify(Boolean(isTfa)));
localStorage.removeItem(IS_TFA_KEY);
}
export function setSession({ token, userData, isTfa } = {}) {
if (token !== undefined) {
setToken(token);
}
if (userData !== undefined) {
setUserData(userData);
}
if (isTfa !== undefined) {
setIsTfa(isTfa);
}
}
export function clearSession() {
removeCookie(TOKEN_KEY);
removeCookie(USER_DATA_KEY);
removeCookie(IS_TFA_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(USER_DATA_KEY);
localStorage.removeItem(IS_TFA_KEY);
}
+38
View File
@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+24
View File
@@ -0,0 +1,24 @@
@import "tailwindcss";
:root {
--background: #fafafa;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
html,
body {
min-height: 100%;
}
body {
background: var(--background);
color: var(--foreground);
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
+33
View File
@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "MyKOPKB QMS-Customer App",
description: "MyKOPKB QMS-Customer App",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+9
View File
@@ -0,0 +1,9 @@
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
export default function Home() {
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<CustomerTicketFlow />
</div>
);
}
@@ -0,0 +1,42 @@
import Link from "next/link";
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
import { decodeBranchQrToken } from "@/lib/branchToken";
type PageProps = {
params: Promise<{
token: string;
}>;
};
export default async function BranchQrPage({ params }: PageProps) {
const { token } = await params;
const payload = decodeBranchQrToken(token);
if (!payload) {
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
<p className="text-zinc-600">
This branch QR link is not valid. Scan again or enter a company code.
</p>
<Link
href="/"
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
>
Enter company code
</Link>
</main>
</div>
);
}
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<CustomerTicketFlow
initialTenantCode={payload.tenantCode}
initialBranchId={payload.branchId}
/>
</div>
);
}
@@ -0,0 +1,42 @@
import Link from "next/link";
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
type PageProps = {
params: Promise<{
tenantCode: string;
branchId: string;
}>;
};
export default async function BranchTicketPage({ params }: PageProps) {
const { tenantCode, branchId: branchIdParam } = await params;
const branchId = Number(branchIdParam);
if (!Number.isFinite(branchId)) {
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
<p className="text-zinc-600">
This branch QR link is not valid. Scan again or enter a company code.
</p>
<Link
href="/"
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
>
Enter company code
</Link>
</main>
</div>
);
}
return (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
<CustomerTicketFlow
initialTenantCode={tenantCode}
initialBranchId={branchId}
/>
</div>
);
}
@@ -0,0 +1,446 @@
"use client";
import Link from "next/link";
import { FormEvent, useEffect, useRef, useState } from "react";
import {
Branch,
Service,
Tenant,
Ticket,
createTicket,
getBranchServices,
getBranches,
getTenant,
getTicketsForDevice,
} from "@/lib/api";
import { getDeviceToken } from "@/lib/device";
type Step = "tenant" | "branch" | "service" | "ticket";
type Props = {
initialTenantCode?: string;
initialBranchId?: number;
};
function pickLatestTicket(
tickets: Ticket[],
branchId?: number
): Ticket | null {
const scoped =
branchId != null
? tickets.filter((item) => item.branch.id === branchId)
: tickets;
if (scoped.length === 0) {
return null;
}
return [...scoped].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
)[0];
}
export default function CustomerTicketFlow({
initialTenantCode,
initialBranchId,
}: Props) {
const fromQr =
initialTenantCode != null &&
initialTenantCode !== "" &&
initialBranchId != null &&
Number.isFinite(initialBranchId);
const [step, setStep] = useState<Step>(fromQr ? "service" : "tenant");
const [tenantCode, setTenantCode] = useState(
initialTenantCode?.trim().toUpperCase() ?? ""
);
const [tenant, setTenant] = useState<Tenant | null>(null);
const [branches, setBranches] = useState<Branch[]>([]);
const [services, setServices] = useState<Service[]>([]);
const [selectedBranch, setSelectedBranch] = useState<Branch | null>(null);
const [ticket, setTicket] = useState<Ticket | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const bootstrapped = useRef(false);
useEffect(() => {
if (bootstrapped.current) return;
bootstrapped.current = true;
async function bootstrap() {
setLoading(true);
setError(null);
try {
const deviceToken = getDeviceToken();
if (deviceToken) {
const responses = await getTicketsForDevice(deviceToken);
const latest = pickLatestTicket(
responses.map((response) => response.ticket),
fromQr ? initialBranchId : undefined
);
if (latest) {
setTicket(latest);
setSelectedBranch({
id: latest.branch.id,
name: latest.branch.name,
tellerStations: latest.branch.tellerStations ?? [],
});
setStep("ticket");
setLoading(false);
return;
}
}
} catch {
// No saved ticket — continue into the normal flow.
}
if (!fromQr) {
setLoading(false);
return;
}
const code = initialTenantCode!.trim().toUpperCase();
const branchId = initialBranchId!;
try {
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
const branch = branchData.find((item) => item.id === branchId);
if (!branch) {
setError(
"This branch was not found. Scan the QR again or enter a company code."
);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
return;
}
const branchServices = await getBranchServices(code, branch.id);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setSelectedBranch(branch);
setServices(branchServices);
setStep("service");
} catch {
setError(
"Could not open this branch link. Scan the QR again or enter a company code."
);
setStep("tenant");
} finally {
setLoading(false);
}
}
void bootstrap();
}, [fromQr, initialTenantCode, initialBranchId]);
async function handleTenantSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const code = tenantCode.trim().toUpperCase();
if (!code) {
setError("Enter a company code.");
return;
}
setLoading(true);
setError(null);
try {
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
} catch {
setError("Could not find that company. Check the code and try again.");
} finally {
setLoading(false);
}
}
async function handleSelectBranch(branch: Branch) {
if (!tenant) return;
setLoading(true);
setError(null);
setSelectedBranch(branch);
try {
const branchServices = await getBranchServices(tenant.code, branch.id);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not load services for this branch.");
} finally {
setLoading(false);
}
}
async function handleSelectService(service: Service) {
if (!selectedBranch) return;
setLoading(true);
setError(null);
try {
const response = await createTicket({
branchId: selectedBranch.id,
serviceId: service.id,
deviceToken: getDeviceToken(),
});
setTicket(response.ticket);
setSelectedBranch(response.ticket.branch);
setStep("ticket");
} catch {
setError(
"Could not get a ticket number. Make sure this branch has services assigned."
);
} finally {
setLoading(false);
}
}
async function resetFlow() {
setTicket(null);
setError(null);
if (fromQr && initialTenantCode && initialBranchId != null) {
setLoading(true);
try {
const code = initialTenantCode.trim().toUpperCase();
const [tenantData, branchData] = await Promise.all([
getTenant(code),
getBranches(code),
]);
const branch = branchData.find((item) => item.id === initialBranchId);
if (!branch) {
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setStep("branch");
return;
}
const branchServices = await getBranchServices(code, branch.id);
setTenant(tenantData);
setBranches(branchData);
setTenantCode(code);
setSelectedBranch(branch);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not reload services. Try scanning the QR again.");
setStep("tenant");
} finally {
setLoading(false);
}
return;
}
if (selectedBranch && tenant) {
setLoading(true);
try {
const branchServices = await getBranchServices(
tenant.code,
selectedBranch.id
);
setServices(branchServices);
setStep("service");
} catch {
setError("Could not load services for this branch.");
setStep("branch");
} finally {
setLoading(false);
}
return;
}
setStep(tenant ? "branch" : "tenant");
}
function startOver() {
setStep("tenant");
setTenantCode("");
setTenant(null);
setBranches([]);
setSelectedBranch(null);
setServices([]);
setTicket(null);
setError(null);
}
const welcomeCopy = fromQr
? "Pilih perkhidmatan untuk mendapatkan nombor."
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12">
<header className="space-y-2">
<p className="text-sm tracking-wide text-zinc-500 uppercase">
Sistem Nombor Giliran
</p>
<h1 className="text-3xl font-semibold tracking-tight text-zinc-900">
{tenant?.name ?? "Dapatkan tiket"}
</h1>
<p className="text-base text-zinc-600">
{tenant?.welcomeMessage ?? welcomeCopy}
</p>
</header>
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</p>
) : null}
{fromQr && loading && step === "service" && !selectedBranch ? (
<p className="text-sm text-zinc-600">Loading perkhidmatan branch</p>
) : null}
{step === "tenant" ? (
<form onSubmit={handleTenantSubmit} className="space-y-4">
<label className="block space-y-2">
<span className="text-sm font-medium text-zinc-700">
Kod syarikat
</span>
<input
value={tenantCode}
onChange={(event) => setTenantCode(event.target.value)}
className="w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 outline-none focus:border-zinc-900"
placeholder="Kod syarikat"
autoComplete="off"
disabled={loading}
/>
</label>
<button
type="submit"
disabled={loading || !tenantCode.trim()}
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white disabled:opacity-60"
>
{loading ? "Loading…" : "Lanjutkan"}
</button>
</form>
) : null}
{step === "branch" ? (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-medium text-zinc-900">Choose a branch</h2>
<button
type="button"
onClick={startOver}
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Change company
</button>
</div>
{branches.length === 0 ? (
<p className="text-sm text-zinc-600">No branches available.</p>
) : (
<ul className="space-y-2">
{branches.map((branch) => (
<li key={branch.id}>
<button
type="button"
disabled={loading}
onClick={() => handleSelectBranch(branch)}
className="flex w-full items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-3 text-left text-zinc-900 transition hover:border-zinc-400 disabled:opacity-60"
>
<span>{branch.name}</span>
<span className="text-zinc-400"></span>
</button>
</li>
))}
</ul>
)}
</section>
) : null}
{step === "service" && selectedBranch ? (
<section className="space-y-4">
<div className="flex items-center justify-between gap-3">
<h2 className="text-lg font-medium text-zinc-900">
Perkhidmatan di Cawangan {selectedBranch.name}
</h2>
{fromQr ? (
<Link
href="/"
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Masukkan kod perkhidmatan
</Link>
) : (
<button
type="button"
onClick={() => {
setStep("branch");
setSelectedBranch(null);
setServices([]);
setError(null);
}}
className="text-sm text-zinc-500 underline-offset-2 hover:underline"
>
Kembali
</button>
)}
</div>
{services.length === 0 ? (
<p className="text-sm text-zinc-600">
Tiada perkhidmatan yang ditugaskan kepada branch ini. Hubungkan
perkhidmatan melalui grup branch dalam aplikasi admin, kemudian
cuba lagi.
</p>
) : (
<ul className="space-y-2">
{services.map((service) => (
<li key={service.id}>
<button
type="button"
disabled={loading}
onClick={() => handleSelectService(service)}
className="flex w-full items-center justify-between rounded-md border border-zinc-200 bg-white px-4 py-3 text-left text-zinc-900 transition hover:border-zinc-400 disabled:opacity-60"
>
<span>{service.name}</span>
<span className="text-sm text-zinc-500">
{loading ? "…" : "Dapatkan nombor"}
</span>
</button>
</li>
))}
</ul>
)}
</section>
) : null}
{step === "ticket" && ticket ? (
<section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center">
<p className="text-sm tracking-wide text-zinc-500 uppercase">
Nombor tiket anda
</p>
<p className="text-6xl font-semibold tracking-tight text-zinc-900">
{ticket.number}
</p>
<div className="space-y-1 text-sm text-zinc-600">
<p>{ticket.service.name}</p>
<p>{ticket.branch.name}</p>
</div>
<button
type="button"
onClick={resetFlow}
className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white"
>
Dapatkan tiket lain
</button>
</section>
) : null}
</main>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+88
View File
@@ -0,0 +1,88 @@
import { SERVER_URL } from "./constants";
export type Tenant = {
id: number;
code: string;
name: string;
welcomeMessage: string;
font: string | null;
logo?: { id: number; base64Logo: string | null } | null;
};
export type Branch = {
id: number;
name: string;
tellerStations: { id: number; name: string }[];
};
export type Service = {
id: number;
name: string;
};
export type Ticket = {
id: number;
number: string;
createdAt: string;
service: Service;
branch: Branch;
station: { id: number; name: string } | null;
};
export type TicketResponse = {
ticket: Ticket;
stations: { id: number; name: string }[];
};
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${SERVER_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
});
if (!response.ok) {
throw new Error(`Request failed (${response.status}) for ${path}`);
}
return response.json() as Promise<T>;
}
export function getTenant(code: string) {
return request<Tenant>(`/api/v1/tenants/${encodeURIComponent(code)}`);
}
export function getBranches(tenantCode: string) {
return request<Branch[]>(
`/api/v1/branches/${encodeURIComponent(tenantCode)}`
);
}
export function getBranchServices(tenantCode: string, branchId: number) {
return request<Service[]>(
`/api/v1/branches/${encodeURIComponent(tenantCode)}/${branchId}/services`
);
}
export function createTicket(input: {
branchId: number;
serviceId: number;
deviceToken: string;
}) {
return request<TicketResponse>("/api/v1/tickets", {
method: "POST",
body: JSON.stringify(input),
});
}
export function getTicketById(ticketId: number | string) {
return request<Ticket>(`/api/v1/tickets/${ticketId}`);
}
export function getTicketsForDevice(deviceToken: string) {
return request<TicketResponse[]>(
`/api/v1/tickets/devices/${encodeURIComponent(deviceToken)}`
);
}
+67
View File
@@ -0,0 +1,67 @@
const DEFAULT_QR_SECRET = "qms-branch-qr-v1";
function getQrSecret() {
return process.env.QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
}
function toBase64Url(bytes: Uint8Array) {
let binary = "";
bytes.forEach((byte) => {
binary += String.fromCharCode(byte);
});
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function fromBase64Url(token: string) {
const padded = token.replace(/-/g, "+").replace(/_/g, "/");
const padLength = (4 - (padded.length % 4)) % 4;
const base64 = padded + "=".repeat(padLength);
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function xorBytes(bytes: Uint8Array, secret: string) {
const key = new TextEncoder().encode(secret);
return bytes.map((byte, index) => byte ^ key[index % key.length]);
}
export type BranchQrPayload = {
tenantCode: string;
branchId: number;
};
export function encodeBranchQrToken(
tenantCode: string,
branchId: number,
secret = getQrSecret()
) {
const plain = `v1:${tenantCode.trim().toUpperCase()}:${Number(branchId)}`;
const plainBytes = new TextEncoder().encode(plain);
return toBase64Url(xorBytes(plainBytes, secret));
}
export function decodeBranchQrToken(
token: string,
secret = getQrSecret()
): BranchQrPayload | null {
try {
const plain = new TextDecoder().decode(
xorBytes(fromBase64Url(token), secret)
);
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
if (!match) return null;
return {
tenantCode: match[1],
branchId: Number(match[2]),
};
} catch {
return null;
}
}
+3
View File
@@ -0,0 +1,3 @@
export const SERVER_URL = process.env.NEXT_PUBLIC_API_URL;
export const DEVICE_TOKEN_KEY = "qms_device_token";
+20
View File
@@ -0,0 +1,20 @@
import { DEVICE_TOKEN_KEY } from "./constants";
export function getDeviceToken(): string {
if (typeof window === "undefined") {
return "";
}
const existing = window.localStorage.getItem(DEVICE_TOKEN_KEY);
if (existing) {
return existing;
}
const token =
typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
window.localStorage.setItem(DEVICE_TOKEN_KEY, token);
return token;
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
devIndicators: false,
};
export default nextConfig;
+26
View File
@@ -0,0 +1,26 @@
{
"name": "customer-app",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.10",
"react": "19.2.4",
"react-dom": "19.2.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"typescript": "^5"
}
}
+4102
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

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

After

Width:  |  Height:  |  Size: 391 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

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

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+14 -11
View File
@@ -1,13 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBQMS Teller App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyKOPKB QMS-Teller App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
Binary file not shown.
+61 -8
View File
@@ -1,20 +1,73 @@
import React from 'react';
import { Route, Routes } from 'react-router-dom';
import React, { useEffect, useState } from 'react';
import { Route, Routes, useLocation } from 'react-router-dom';
import StationIntroPage from './pages/StationIntroPage/StationIntroPage.jsx';
import ShowQueuesForTellerPage from './pages/ShowQueuesForTellerPage/ShowQueuesForTellerPage';
import Header from './components/Header/Header.jsx';
import CurrentTicketPage from './pages/CurrentTicketPage/CurrentTicketPage.jsx';
import BranchDisplayPage from './pages/BranchDisplayPage/BranchDisplayPage.jsx';
import LoginPage from './pages/LoginPage/LoginPage.jsx';
import AuthGuard from './components/AuthGuard/AuthGuard.jsx';
import { UserContext } from './context/UserContext.jsx';
import { clearSession, getToken, getUserData } from './utils/session.js';
import { SERVER_URL } from './constants.js';
import { fetchData } from './fetching/Fetch.js';
export default function App() {
const [user, setUser] = useState(() => getUserData());
const location = useLocation();
const isDisplayRoute = location.pathname.startsWith('/display');
useEffect(() => {
document.body.classList.toggle('display-mode', isDisplayRoute);
return () => document.body.classList.remove('display-mode');
}, [isDisplayRoute]);
useEffect(() => {
const token = getToken();
if (!token) {
return;
}
fetchData(`${SERVER_URL}/api/v1/auth`, 'GET').then(({ success }) => {
if (success) {
setUser(getUserData());
} else {
clearSession();
setUser(null);
}
});
}, []);
return (
<>
<Header />
<UserContext.Provider value={{ user, setUser }}>
{isDisplayRoute ? null : <Header />}
<Routes>
<Route exact path='/' element={<StationIntroPage />} />
<Route exact path="/teller-queue/:stationId" element={<ShowQueuesForTellerPage />} />
<Route path="/display/:stationId" element={<CurrentTicketPage/>} />
<Route exact path="/login" element={<LoginPage />} />
<Route
exact
path="/"
element={
<AuthGuard>
<StationIntroPage />
</AuthGuard>
}
/>
<Route
exact
path="/teller-queue/:stationId"
element={
<AuthGuard>
<ShowQueuesForTellerPage />
</AuthGuard>
}
/>
{/* Public display boards for waiting area screens */}
<Route
path="/display/branch/:tenantCode/:branchId"
element={<BranchDisplayPage />}
/>
<Route path="/display/:stationId" element={<CurrentTicketPage />} />
</Routes>
</>
</UserContext.Provider>
);
}
@@ -0,0 +1,37 @@
import { useContext, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserContext } from '../../context/UserContext.jsx';
import { getUserData } from '../../utils/session.js';
import { ROLES } from '../../constants.js';
const TELLER_ROLES = [
ROLES.ROLE_USER,
ROLES.ROLE_SUPER_ADMIN,
ROLES.ROLE_BRANCH_ADMIN,
];
export default function AuthGuard({ children }) {
const navigate = useNavigate();
const { user: contextUser } = useContext(UserContext);
const [allowed, setAllowed] = useState(false);
useEffect(() => {
const user = contextUser ?? getUserData();
const roles = Array.isArray(user?.roles) ? user.roles : [];
const hasAccess = roles.some((role) => TELLER_ROLES.includes(role));
if (!user || !hasAccess) {
setAllowed(false);
navigate('/login', { replace: true });
return;
}
setAllowed(true);
}, [contextUser, navigate]);
if (!allowed) {
return null;
}
return <>{children}</>;
}
@@ -15,4 +15,25 @@ header.main-header {
margin-top: 7px;
margin-right: auto;
margin-left: 2%;
}
.header-user {
display: flex;
align-items: center;
gap: 12px;
margin-right: 2%;
}
.header-email {
font-size: 14px;
color: #334257;
}
.header-logout-btn {
border: 1px solid #334257;
background: white;
color: #334257;
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
}
@@ -1,13 +1,34 @@
import './Header.css';
import { useContext } from 'react';
import { useNavigate } from 'react-router-dom';
import { UserContext } from '../../context/UserContext.jsx';
import { clearSession } from '../../utils/session.js';
import { clearActiveStation } from '../../utils/activeStation.js';
export default function Header() {
const navigate = useNavigate();
const { user, setUser } = useContext(UserContext);
function handleLogout() {
clearSession();
clearActiveStation();
setUser(null);
navigate('/login', { replace: true });
}
return (
<header className="main-header">
<h2 className="header-logo" onClick={() => navigate(`/`)}>BBQMS</h2>
<h2 className="header-logo" onClick={() => navigate(user ? '/' : '/login')}>
MyKOPKB QMS-Teller
</h2>
{user ? (
<div className="header-user">
<span className="header-email">{user.email}</span>
<button type="button" className="header-logout-btn" onClick={handleLogout}>
Logout
</button>
</div>
) : null}
</header>
);
}
}
+10
View File
@@ -1 +1,11 @@
export const SERVER_URL = 'http://localhost:8080';
export const GOLD_PRICE_URL =
import.meta.env.VITE_GOLD_PRICE_URL ??
'https://apiujrah.erahn.com.my/api/harga_emas';
export const ROLES = {
ROLE_USER: 'ROLE_USER',
ROLE_SUPER_ADMIN: 'ROLE_SUPER_ADMIN',
ROLE_BRANCH_ADMIN: 'ROLE_BRANCH_ADMIN',
};
@@ -0,0 +1,6 @@
import { createContext } from 'react';
export const UserContext = createContext({
user: null,
setUser: () => {},
});
+8 -7
View File
@@ -1,12 +1,14 @@
/*
Koristiti ovu funkciju za fetchanje u buducnosti kad god je to moguce.
*/
import { getToken, setToken } from '../utils/session.js';
export async function fetchData(url, method, body) {
const headers = new Headers();
const token = localStorage.getItem('token');
const token = getToken();
if (token) {
headers.append('Authorization', `Bearer ${ token }`);
headers.append('Authorization', `Bearer ${token}`);
}
headers.append('Content-Type', 'application/json');
@@ -14,7 +16,7 @@ export async function fetchData(url, method, body) {
const res = await fetch(url, {
method: method || 'GET',
headers: headers,
body: body ? JSON.stringify(body) : null
body: body ? JSON.stringify(body) : null,
});
if (!res) {
@@ -24,12 +26,11 @@ export async function fetchData(url, method, body) {
const data = res.ok && res.body ? await res.json() : null;
if (res.ok) {
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
const newToken = res.headers.get('Auth-Token');
if (newToken) {
localStorage.setItem('token', newToken);
setToken(newToken);
}
}
return { data: data, success: res.ok };
}
return { data, success: res.ok };
}
+6
View File
@@ -14,6 +14,12 @@ body {
background-color: ghostwhite;
}
body.display-mode {
padding: 0;
background-color: #0f172a;
overflow-x: hidden;
}
:root {
/* ovdje definisite konstante boje i sl. koje cete koristiti na vise mjesta */
--blue: #334257;
@@ -0,0 +1,148 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { fetchData } from '../../fetching/Fetch.js';
import { SERVER_URL } from '../../constants.js';
function resolveMediaUrl(mediaUrl) {
if (!mediaUrl) return null;
return mediaUrl.startsWith('http') ? mediaUrl : `${SERVER_URL}${mediaUrl}`;
}
export default function AdCarousel({ tenantCode }) {
const [ads, setAds] = useState([]);
const [index, setIndex] = useState(0);
const [error, setError] = useState(null);
const timerRef = useRef(null);
const videoRef = useRef(null);
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
const loadAds = useCallback(async () => {
if (!tenantCode) return;
try {
const response = await fetchData(
`${SERVER_URL}/api/v1/ads/${encodeURIComponent(tenantCode)}/active`,
'GET'
);
if (!response.success) {
setError('Could not load advertisements.');
return;
}
const next = Array.isArray(response.data) ? response.data : [];
setAds(next);
setError(null);
setIndex((current) => (next.length === 0 ? 0 : current % next.length));
} catch (err) {
console.error(err);
setError('Could not load advertisements.');
}
}, [tenantCode]);
useEffect(() => {
loadAds();
const interval = setInterval(loadAds, 60 * 1000);
return () => clearInterval(interval);
}, [loadAds]);
const goNext = useCallback(() => {
setIndex((current) => {
if (ads.length === 0) return 0;
return (current + 1) % ads.length;
});
}, [ads.length]);
const currentAd = ads[index] ?? null;
useEffect(() => {
clearTimer();
if (!currentAd) return undefined;
if (currentAd.mediaType === 'IMAGE') {
const durationMs = Math.max(1, Number(currentAd.durationSeconds) || 10) * 1000;
timerRef.current = setTimeout(goNext, durationMs);
return clearTimer;
}
// Videos advance on ended; fallback if play fails
timerRef.current = setTimeout(goNext, 5 * 60 * 1000);
return clearTimer;
}, [currentAd, goNext, clearTimer]);
useEffect(() => {
const video = videoRef.current;
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
const onEnded = () => goNext();
video.addEventListener('ended', onEnded);
video.muted = true;
video.playsInline = true;
const playPromise = video.play();
if (playPromise?.catch) {
playPromise.catch(() => {
// Autoplay blocked — advance after durationSeconds fallback
clearTimer();
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
timerRef.current = setTimeout(goNext, durationMs);
});
}
return () => {
video.removeEventListener('ended', onEnded);
};
}, [currentAd, goNext, clearTimer]);
if (error) {
return (
<section className="branch-display__ad-panel">
<h2 className="branch-display__section-title">Pengiklanan</h2>
<p className="branch-display__empty">{error}</p>
</section>
);
}
if (ads.length === 0) {
return (
<section className="branch-display__ad-panel">
<h2 className="branch-display__section-title">Pengiklanan</h2>
<p className="branch-display__empty">Tiada pengiklanan aktif.</p>
</section>
);
}
const src = resolveMediaUrl(currentAd.mediaUrl);
return (
<section className="branch-display__ad-panel">
<div className="branch-display__ad-header">
<h2 className="branch-display__section-title">Pengiklanan</h2>
</div>
<div className="branch-display__ad-stage">
{currentAd.mediaType === 'VIDEO' ? (
<video
key={currentAd.id}
ref={videoRef}
className="branch-display__ad-media"
src={src}
muted
playsInline
autoPlay
/>
) : (
<img
key={currentAd.id}
className="branch-display__ad-media"
src={src}
alt={currentAd.title || currentAd.fileName || 'Advertisement'}
/>
)}
</div>
{currentAd.title ? (
<p className="branch-display__ad-caption">{currentAd.title}</p>
) : null}
</section>
);
}
@@ -0,0 +1,397 @@
.branch-display {
--bd-bg: #071a10;
--bd-bg-soft: #0c2416;
--bd-surface: #12301f;
--bd-surface-raised: #183c28;
--bd-border: #245537;
--bd-border-strong: #2f6b45;
--bd-text: #f3faf5;
--bd-muted: #9bc4a8;
--bd-muted-strong: #c5e0cf;
--bd-accent: #f0d060;
--bd-accent-bright: #ffe566;
--bd-accent-deep: #d4a017;
--bd-green: #3ecf7a;
--bd-error-bg: #5c1a1a;
--bd-error-text: #fecaca;
position: relative;
height: 100vh;
width: 100%;
box-sizing: border-box;
padding: 1rem 1.5rem 1rem;
background:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%),
radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
linear-gradient(165deg, var(--bd-bg-soft) 0%, var(--bd-bg) 55%, #05140c 100%);
color: var(--bd-text);
font-family: system-ui, -apple-system, Segoe UI, sans-serif;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 0.75rem;
overflow: hidden;
}
.branch-display__sound-enable {
position: absolute;
top: 0.75rem;
left: 50%;
transform: translateX(-50%);
z-index: 20;
border: 1px solid var(--bd-accent-deep);
background: linear-gradient(180deg, var(--bd-accent-bright) 0%, var(--bd-accent) 100%);
color: #1a2e14;
border-radius: 999px;
padding: 0.45rem 1rem;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 12px rgba(240, 208, 96, 0.35);
}
.branch-display__header {
margin: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.branch-display__brand {
display: flex;
align-items: center;
gap: 0.85rem;
min-width: 0;
}
.branch-display__logo {
height: clamp(2.5rem, 4.5vw, 3.5rem);
width: auto;
flex-shrink: 0;
border-radius: 0.35rem;
object-fit: contain;
background: #fff;
}
.branch-display__eyebrow {
margin: 0;
font-size: 0.75rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--bd-accent);
flex-shrink: 0;
}
.branch-display__title {
margin: 0;
font-size: clamp(1.4rem, 2.2vw, 2rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--bd-text);
line-height: 1.2;
}
.branch-display__error {
margin: 0;
padding: 0.5rem 0.75rem;
border-radius: 0.4rem;
background: var(--bd-error-bg);
color: var(--bd-error-text);
font-size: 0.9rem;
}
.branch-display__main {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr);
gap: 0.85rem;
min-height: 0;
overflow: hidden;
}
@media (max-width: 960px) {
.branch-display {
height: auto;
min-height: 100vh;
overflow: auto;
grid-template-rows: auto;
}
.branch-display__main {
grid-template-columns: 1fr;
overflow: visible;
}
.branch-display__section--waiting {
max-height: none;
}
.branch-display__section--gold {
flex: none;
min-height: 16rem;
}
}
.branch-display__queue-column {
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.65rem;
overflow: hidden;
}
.branch-display__section {
margin: 0;
min-height: 0;
}
.branch-display__section--waiting {
flex: 0 1 auto;
max-height: 28%;
display: flex;
flex-direction: column;
min-height: 0;
}
.branch-display__section--gold {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
.branch-display__section-title {
margin: 0 0 0.45rem;
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--bd-muted-strong);
}
.branch-display__stations {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.55rem;
}
.branch-display__station-card {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.65rem 0.5rem;
border-radius: 0.5rem;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
text-align: center;
}
.branch-display__station-name {
margin: 0;
font-size: 0.8rem;
color: var(--bd-muted);
}
.branch-display__ticket-number {
margin: 0;
font-size: clamp(1.75rem, 3.2vw, 2.75rem);
font-weight: 700;
line-height: 1;
letter-spacing: -0.03em;
color: var(--bd-accent-bright);
text-shadow: 0 0 24px rgba(255, 229, 102, 0.25);
}
.branch-display__ticket-number--idle {
color: #4a7a5c;
text-shadow: none;
}
.branch-display__station-service {
margin: 0;
font-size: 0.75rem;
color: var(--bd-muted-strong);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.branch-display__waiting-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
flex-shrink: 0;
}
.branch-display__waiting-header .branch-display__section-title {
margin-bottom: 0;
}
.branch-display__waiting-count {
margin: 0;
font-size: 0.85rem;
color: var(--bd-accent);
}
.branch-display__empty {
margin: 0;
color: var(--bd-muted);
font-size: 0.9rem;
}
.branch-display__waiting-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
flex: 1;
min-height: 0;
overflow: auto;
}
.branch-display__waiting-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.4rem 0.7rem;
border-radius: 0.4rem;
background: var(--bd-surface);
border: 1px solid var(--bd-border);
border-left: 3px solid var(--bd-green);
flex-shrink: 0;
}
.branch-display__waiting-number {
font-size: clamp(1.1rem, 1.8vw, 1.5rem);
font-weight: 700;
letter-spacing: -0.02em;
color: var(--bd-accent);
}
.branch-display__waiting-service {
font-size: 0.85rem;
color: var(--bd-muted-strong);
}
.branch-display__ad-panel {
min-width: 0;
min-height: 0;
height: 100%;
padding: 0.65rem;
border-radius: 0.6rem;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
display: flex;
flex-direction: column;
overflow: hidden;
}
.branch-display__ad-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.4rem;
flex-shrink: 0;
}
.branch-display__ad-header .branch-display__section-title {
margin-bottom: 0;
}
.branch-display__ad-stage {
position: relative;
flex: 1;
min-height: 0;
width: 100%;
border-radius: 0.4rem;
overflow: hidden;
background: var(--bd-bg);
border: 1px solid var(--bd-border);
}
.branch-display__ad-media {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
background: var(--bd-bg);
}
.branch-display__ad-caption {
margin: 0.35rem 0 0;
font-size: 0.8rem;
color: var(--bd-muted-strong);
text-align: center;
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.branch-display__section--gold .branch-display__waiting-header {
margin-bottom: 0.35rem;
}
.branch-display__gold-table-wrap {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-radius: 0.5rem;
border: 1px solid var(--bd-border);
background: var(--bd-surface);
scrollbar-width: none;
}
.branch-display__gold-table-wrap::-webkit-scrollbar {
display: none;
}
.branch-display__gold-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.branch-display__gold-table th,
.branch-display__gold-table td {
padding: 0.45rem 0.65rem;
text-align: left;
border-bottom: 1px solid var(--bd-border);
line-height: 1.3;
}
.branch-display__gold-table th {
font-size: 0.7rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--bd-accent);
font-weight: 600;
background: var(--bd-bg-soft);
position: sticky;
top: 0;
z-index: 1;
}
.branch-display__gold-table td:nth-child(3) {
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--bd-accent-bright);
white-space: nowrap;
}
.branch-display__gold-table tbody tr:last-child td {
border-bottom: none;
}
.branch-display__gold-table tbody tr:hover td {
background: rgba(62, 207, 122, 0.06);
}
@@ -0,0 +1,345 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { fetchData } from '../../fetching/Fetch.js';
import { GOLD_PRICE_URL, SERVER_URL } from '../../constants.js';
import {
hasNewServingCall,
playQueueCallSound,
servingSnapshot,
unlockQueueCallSound,
} from '../../utils/queueCallSound.js';
import AdCarousel from './AdCarousel.jsx';
import './BranchDisplayPage.css';
function formatGoldPrice(value) {
const amount = Number(value);
if (!Number.isFinite(amount)) return value ?? '—';
return amount.toLocaleString('en-MY', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
}
export default function BranchDisplayPage() {
const { tenantCode, branchId } = useParams();
const [branchName, setBranchName] = useState('');
const [stations, setStations] = useState([]);
const [tickets, setTickets] = useState([]);
const [goldPrices, setGoldPrices] = useState([]);
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
const [goldError, setGoldError] = useState(null);
const [error, setError] = useState(null);
const [soundReady, setSoundReady] = useState(false);
const goldTableWrapRef = useRef(null);
const previousServingRef = useRef(null);
const loadDisplay = useCallback(async () => {
if (!tenantCode || !branchId) return;
try {
const [stationsRes, queueRes, branchesRes] = await Promise.all([
fetchData(
`${SERVER_URL}/api/v1/stations/${encodeURIComponent(tenantCode)}/${branchId}`,
'GET'
),
fetchData(
`${SERVER_URL}/api/v1/branches/${encodeURIComponent(tenantCode)}/${branchId}/queue?activeOnly=true&sort=createdAt,asc`,
'GET'
),
fetchData(
`${SERVER_URL}/api/v1/branches/${encodeURIComponent(tenantCode)}`,
'GET'
),
]);
if (!stationsRes.success || !queueRes.success) {
setError('Could not load branch display.');
return;
}
setError(null);
setStations(Array.isArray(stationsRes.data) ? stationsRes.data : []);
setTickets(Array.isArray(queueRes.data) ? queueRes.data : []);
if (branchesRes.success && Array.isArray(branchesRes.data)) {
const branch = branchesRes.data.find(
(item) => Number(item.id) === Number(branchId)
);
if (branch?.name) {
setBranchName(branch.name);
}
} else if (queueRes.data?.[0]?.branch?.name) {
setBranchName(queueRes.data[0].branch.name);
}
} catch (err) {
console.error(err);
setError('Could not load branch display.');
}
}, [tenantCode, branchId]);
const loadGoldPrices = useCallback(async () => {
try {
const response = await fetch(GOLD_PRICE_URL);
if (!response.ok) {
throw new Error(`Gold price request failed (${response.status})`);
}
const data = await response.json();
const rows = Array.isArray(data) ? data : [];
setGoldPrices(rows);
setGoldUpdatedAt(rows[0]?.tarikhupdate ?? null);
setGoldError(null);
} catch (err) {
console.error(err);
setGoldError('Could not load gold prices.');
}
}, []);
useEffect(() => {
loadDisplay();
const interval = setInterval(loadDisplay, 3000);
return () => clearInterval(interval);
}, [loadDisplay]);
useEffect(() => {
loadGoldPrices();
const interval = setInterval(loadGoldPrices, 5 * 60 * 1000);
return () => clearInterval(interval);
}, [loadGoldPrices]);
useEffect(() => {
const wrap = goldTableWrapRef.current;
if (!wrap || goldPrices.length === 0) return undefined;
let rafId = 0;
let pauseUntil = 0;
let direction = 1;
const speedPxPerSec = 28;
const pauseMs = 1600;
let lastTs = 0;
const step = (now) => {
if (!lastTs) lastTs = now;
const dt = Math.min(now - lastTs, 50);
lastTs = now;
const maxScroll = wrap.scrollHeight - wrap.clientHeight;
if (maxScroll <= 2) {
rafId = requestAnimationFrame(step);
return;
}
if (now < pauseUntil) {
rafId = requestAnimationFrame(step);
return;
}
wrap.scrollTop += (speedPxPerSec * dt * direction) / 1000;
if (direction > 0 && wrap.scrollTop >= maxScroll - 1) {
wrap.scrollTop = maxScroll;
direction = -1;
pauseUntil = now + pauseMs;
} else if (direction < 0 && wrap.scrollTop <= 1) {
wrap.scrollTop = 0;
direction = 1;
pauseUntil = now + pauseMs;
}
rafId = requestAnimationFrame(step);
};
const startId = requestAnimationFrame(() => {
wrap.scrollTop = 0;
lastTs = 0;
rafId = requestAnimationFrame(step);
});
return () => {
cancelAnimationFrame(startId);
cancelAnimationFrame(rafId);
};
}, [goldPrices]);
const servingByStationId = useMemo(() => {
const map = new Map();
for (const ticket of tickets) {
if (ticket.station?.id != null) {
map.set(Number(ticket.station.id), ticket);
}
}
return map;
}, [tickets]);
useEffect(() => {
const next = servingSnapshot(tickets);
const previous = previousServingRef.current;
if (hasNewServingCall(previous, next)) {
playQueueCallSound();
}
previousServingRef.current = next;
}, [tickets]);
const waiting = useMemo(
() => tickets.filter((ticket) => ticket.station == null),
[tickets]
);
const sortedStations = useMemo(
() => [...stations].sort((a, b) => String(a.name).localeCompare(String(b.name))),
[stations]
);
return (
<div
className="branch-display"
onClick={() => {
if (!soundReady) {
unlockQueueCallSound();
setSoundReady(true);
}
}}
>
{!soundReady ? (
<button
type="button"
className="branch-display__sound-enable"
onClick={(event) => {
event.stopPropagation();
unlockQueueCallSound();
setSoundReady(true);
}}
>
Enable call sound
</button>
) : null}
<header className="branch-display__header">
<div className="branch-display__brand">
<img
className="branch-display__logo"
src="/logo-arrahn.jpeg"
alt="ar-rahn"
/>
<h1 className="branch-display__title">
Selamat Datang ke Cawangan {branchName}
</h1>
</div>
<p className="branch-display__eyebrow">Sistem Nombor Giliran</p>
</header>
{error ? <p className="branch-display__error">{error}</p> : null}
<div className="branch-display__main">
<div className="branch-display__queue-column">
<section className="branch-display__section">
<h2 className="branch-display__section-title">Sedang Diproses</h2>
{sortedStations.length === 0 ? (
<p className="branch-display__empty">Tiada Stesen di Branch ini.</p>
) : (
<div className="branch-display__stations">
{sortedStations.map((station) => {
const ticket = servingByStationId.get(Number(station.id));
return (
<article
key={station.id}
className="branch-display__station-card"
>
<p className="branch-display__station-name">
{station.name}
</p>
<p
className={
ticket
? 'branch-display__ticket-number'
: 'branch-display__ticket-number branch-display__ticket-number--idle'
}
>
{ticket ? ticket.number : '—'}
</p>
<p className="branch-display__station-service">
{ticket?.service?.name ?? 'Waiting for next'}
</p>
</article>
);
})}
</div>
)}
</section>
<section className="branch-display__section branch-display__section--waiting">
<div className="branch-display__waiting-header">
<h2 className="branch-display__section-title">Giliran</h2>
<p className="branch-display__waiting-count">
{waiting.length} dalam giliran
</p>
</div>
{waiting.length === 0 ? (
<p className="branch-display__empty">Tiada nombor dalam giliran.</p>
) : (
<ul className="branch-display__waiting-list">
{waiting.map((ticket) => (
<li key={ticket.id} className="branch-display__waiting-item">
<span className="branch-display__waiting-number">
{ticket.number}
</span>
</li>
))}
</ul>
)}
</section>
<section className="branch-display__section branch-display__section--gold">
<div className="branch-display__waiting-header">
<h2 className="branch-display__section-title">Harga Emas</h2>
{goldUpdatedAt ? (
<p className="branch-display__waiting-count">
Dikemas kini pada {goldUpdatedAt}
</p>
) : null}
</div>
{goldError ? (
<p className="branch-display__error">{goldError}</p>
) : null}
{!goldError && goldPrices.length === 0 ? (
<p className="branch-display__empty">Loading harga emas</p>
) : null}
{goldPrices.length > 0 ? (
<div
ref={goldTableWrapRef}
className="branch-display__gold-table-wrap"
>
<table className="branch-display__gold-table">
<thead>
<tr>
<th>Karat</th>
<th>Mutu</th>
<th>RM/g</th>
</tr>
</thead>
<tbody>
{goldPrices.map((row) => (
<tr
key={
row.recno ??
`${row.karat}-${row.keterangan}`
}
>
<td>{row.karat}</td>
<td>{row.keterangan}</td>
<td>{formatGoldPrice(row.harga)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</section>
</div>
<AdCarousel tenantCode={tenantCode} />
</div>
</div>
);
}
@@ -0,0 +1,67 @@
#teller-login-form {
width: 420px;
max-width: 100%;
margin: 80px auto 40px;
background-color: #334257;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
#teller-login-form h1 {
margin: 0;
padding: 20px 0;
text-align: center;
font-size: 28px;
font-weight: bold;
color: white;
}
#teller-login-form form {
padding: 20px;
background-color: white;
border-radius: 10px;
}
#teller-login-form .form-group {
margin-bottom: 16px;
}
#teller-login-form label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #555;
}
#teller-login-form input {
width: 100%;
padding: 12px;
border: 1px solid #d0d0d0;
border-radius: 5px;
font-size: 16px;
box-sizing: border-box;
}
#teller-login-form button {
width: 100%;
margin-top: 8px;
padding: 12px;
border: none;
border-radius: 5px;
background-color: #548ca8;
color: white;
font-size: 16px;
font-weight: bold;
cursor: pointer;
}
#teller-login-form button:disabled {
opacity: 0.7;
cursor: default;
}
#teller-login-form .error {
margin: 0 0 12px;
color: #b00020;
font-size: 14px;
}

Some files were not shown because too many files have changed in this diff Show More