Dev/v1.0 #1
@@ -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.
|
||||
|
||||
@@ -31,3 +31,6 @@ build/
|
||||
|
||||
### VS Code ###
|
||||
.vscode/
|
||||
|
||||
### Uploads ###
|
||||
uploads/
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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"}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -0,0 +1 @@
|
||||
@AGENTS.md
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 15 KiB |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)}`
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const SERVER_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
export const DEVICE_TOKEN_KEY = "qms_device_token";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -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 |
@@ -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 |
@@ -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"}
|
||||
@@ -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 |
@@ -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 |
@@ -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"]
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BBQMS Teller App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MyKOPKB QMS-Teller App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
@@ -1,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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: () => {},
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||