2 Commits

44 changed files with 184 additions and 1366 deletions
-112
View File
@@ -1,112 +0,0 @@
name: Deploy Backend to Production
# Manual production deploy — pulls pre-built backend image from the registry
on:
workflow_dispatch:
inputs:
image_tag:
description: "Docker image tag to deploy (commit SHA or version tag, e.g. v1.0.0)"
required: true
type: string
env:
BACKEND_IMAGE: git.koppkb.com/kopkb/qms-be
DEPLOY_DIR: /home/arrahn/Project/qms
jobs:
deploy:
runs-on: host
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ inputs.image_tag }}
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.koppkb.com \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Verify backend image exists in registry
run: |
set -e
IMAGE_TAG="${{ inputs.image_tag }}"
IMAGE="${BACKEND_IMAGE}:${IMAGE_TAG}"
echo "Checking if image exists: ${IMAGE}"
if ! docker manifest inspect "${IMAGE}" > /dev/null 2>&1; then
echo "ERROR: Image ${IMAGE} does not exist in registry!"
exit 1
fi
echo "✓ ${IMAGE} found"
- name: Pull backend image
run: |
set -e
IMAGE_TAG="${{ inputs.image_tag }}"
docker pull "${BACKEND_IMAGE}:${IMAGE_TAG}"
echo "✓ Image pulled successfully"
- name: Sync compose file and deploy
run: |
set -e
IMAGE_TAG="${{ inputs.image_tag }}"
mkdir -p "${DEPLOY_DIR}"
cp be/prod-compose.yml "${DEPLOY_DIR}/docker-compose.yml"
cd "${DEPLOY_DIR}"
if [ ! -f .env ]; then
echo "ERROR: ${DEPLOY_DIR}/.env is missing on the server"
echo "Create it from be/.env.example (or be/.env.production) before deploying."
exit 1
fi
export IMAGE_TAG
export BACKEND_IMAGE
docker compose -f docker-compose.yml pull backend
docker compose -f docker-compose.yml up -d --remove-orphans
echo "✓ Deployed IMAGE_TAG=${IMAGE_TAG}"
- name: Verify deployment
run: |
set -e
cd "${DEPLOY_DIR}"
echo "Waiting for services to start..."
sleep 15
for SERVICE in mysql backend; do
STATUS=$(docker compose -f docker-compose.yml ps --status running --format '{{.Name}}' "$SERVICE" 2>/dev/null || true)
if [ -z "$STATUS" ]; then
echo "ERROR: ${SERVICE} is not running"
docker compose -f docker-compose.yml ps
docker compose -f docker-compose.yml logs --tail=50 "$SERVICE" || true
exit 1
fi
echo "✓ ${SERVICE} is running (${STATUS})"
done
BACKEND_HOST_PORT=$(grep -E '^BACKEND_HOST_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
BACKEND_HOST_PORT=${BACKEND_HOST_PORT:-8080}
# No actuator yet — treat any HTTP response as "server is up"
for i in 1 2 3 4 5 6 7 8; do
CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${BACKEND_HOST_PORT}/" || echo "000")
if [ "${CODE}" != "000" ]; then
echo "✓ Backend is responding on port ${BACKEND_HOST_PORT} (HTTP ${CODE})"
exit 0
fi
echo "Waiting for backend... attempt ${i}/8"
sleep 5
done
echo "ERROR: Backend is not responding on port ${BACKEND_HOST_PORT}"
docker compose -f docker-compose.yml logs --tail=50 backend || true
exit 1
-187
View File
@@ -1,187 +0,0 @@
name: Build Docker Image
on:
push:
# branches:
# - main
tags:
- "v*"
jobs:
build-backend:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.koppkb.com \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build & push backend image
run: |
set -e
IMAGE="git.koppkb.com/kopkb/qms-be"
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
fi
echo "Building and pushing backend: ${TAGS}"
docker buildx build \
--platform linux/amd64 \
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f be/Dockerfile \
${TAGS} \
--push \
be
build-admin:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.koppkb.com \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build & push admin frontend image
run: |
set -e
IMAGE="git.koppkb.com/kopkb/qms-fe-admin"
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
API_URL="${{ secrets.VITE_API_URL }}"
# Branch QR display URL only (public domain via nginx)
BRANCH_QR_BASE_URL="${{ secrets.VITE_BRANCH_QR_BASE_URL }}"
if [ -z "${BRANCH_QR_BASE_URL}" ]; then
BRANCH_QR_BASE_URL="https://qms-customer.erahn.com.my"
fi
if [ -z "${API_URL}" ]; then
echo "ERROR: Set Gitea secret VITE_API_URL (e.g. http://172.16.6.200:8080)"
exit 1
fi
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
fi
echo "Building and pushing admin: ${TAGS}"
echo "VITE_API_URL=${API_URL}"
echo "VITE_BRANCH_QR_BASE_URL=${BRANCH_QR_BASE_URL}"
docker buildx build \
--platform linux/amd64 \
--build-arg VITE_API_URL="${API_URL}" \
--build-arg VITE_BRANCH_QR_BASE_URL="${BRANCH_QR_BASE_URL}" \
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f fe/web/admin-app/Dockerfile \
${TAGS} \
--push \
fe/web/admin-app
build-teller:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.koppkb.com \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build & push teller frontend image
run: |
set -e
IMAGE="git.koppkb.com/kopkb/qms-fe-teller"
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
API_URL="${{ secrets.VITE_API_URL }}"
if [ -z "${API_URL}" ]; then
echo "ERROR: Set Gitea secret VITE_API_URL (e.g. http://172.16.6.200:8080)"
exit 1
fi
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
fi
GOLD_PRICE_URL="${{ secrets.VITE_GOLD_PRICE_URL }}"
if [ -z "${GOLD_PRICE_URL}" ]; then
GOLD_PRICE_URL="https://apiujrah.erahn.com.my/api/harga_emas"
fi
echo "Building and pushing teller: ${TAGS}"
docker buildx build \
--platform linux/amd64 \
--build-arg VITE_API_URL="${API_URL}" \
--build-arg VITE_GOLD_PRICE_URL="${GOLD_PRICE_URL}" \
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f fe/web/teller-app/Dockerfile \
${TAGS} \
--push \
fe/web/teller-app
build-customer:
runs-on: docker
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.koppkb.com \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Build & push customer frontend image
run: |
set -e
IMAGE="git.koppkb.com/kopkb/qms-fe-customer"
TAGS="-t ${IMAGE}:${{ gitea.sha }}"
API_URL="${{ secrets.NEXT_PUBLIC_API_URL }}"
if [ -z "${API_URL}" ]; then
API_URL="${{ secrets.VITE_API_URL }}"
fi
if [ -z "${API_URL}" ]; then
echo "ERROR: Set Gitea secret NEXT_PUBLIC_API_URL or VITE_API_URL (e.g. http://172.16.6.200:8080)"
exit 1
fi
if [ "${{ gitea.ref_type }}" = "tag" ] && echo "${{ gitea.ref_name }}" | grep -q '^v'; then
TAGS="${TAGS} -t ${IMAGE}:${{ gitea.ref_name }}"
fi
echo "Building and pushing customer: ${TAGS}"
docker buildx build \
--platform linux/amd64 \
--build-arg NEXT_PUBLIC_API_URL="${API_URL}" \
--cache-from type=registry,ref=${IMAGE}:buildcache,ignore-error=true \
--cache-to type=registry,ref=${IMAGE}:buildcache,mode=max \
-f fe/web/customer-app/Dockerfile \
${TAGS} \
--push \
fe/web/customer-app
-128
View File
@@ -1,128 +0,0 @@
name: Deploy Frontend to Production
# Manual production deploy — pulls pre-built admin + teller + customer images
on:
workflow_dispatch:
inputs:
image_tag:
description: "Docker image tag to deploy (commit SHA or version tag, e.g. v1.0.0)"
required: true
type: string
env:
ADMIN_IMAGE: git.koppkb.com/kopkb/qms-fe-admin
TELLER_IMAGE: git.koppkb.com/kopkb/qms-fe-teller
CUSTOMER_IMAGE: git.koppkb.com/kopkb/qms-fe-customer
DEPLOY_DIR: /home/arrahn/Project/qms-fe
jobs:
deploy:
runs-on: host
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ inputs.image_tag }}
- name: Login to Docker registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | \
docker login git.koppkb.com \
-u "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Verify frontend images exist in registry
run: |
set -e
IMAGE_TAG="${{ inputs.image_tag }}"
for IMAGE in "${ADMIN_IMAGE}:${IMAGE_TAG}" "${TELLER_IMAGE}:${IMAGE_TAG}" "${CUSTOMER_IMAGE}:${IMAGE_TAG}"; do
echo "Checking if image exists: ${IMAGE}"
if ! docker manifest inspect "${IMAGE}" > /dev/null 2>&1; then
echo "ERROR: Image ${IMAGE} does not exist in registry!"
exit 1
fi
echo "✓ ${IMAGE} found"
done
- name: Pull frontend images
run: |
set -e
IMAGE_TAG="${{ inputs.image_tag }}"
docker pull "${ADMIN_IMAGE}:${IMAGE_TAG}"
docker pull "${TELLER_IMAGE}:${IMAGE_TAG}"
docker pull "${CUSTOMER_IMAGE}:${IMAGE_TAG}"
echo "✓ Images pulled successfully"
- name: Sync compose file and deploy
run: |
set -e
IMAGE_TAG="${{ inputs.image_tag }}"
mkdir -p "${DEPLOY_DIR}"
cp fe/web/docker-compose.yml "${DEPLOY_DIR}/docker-compose.yml"
cd "${DEPLOY_DIR}"
if [ ! -f .env ]; then
echo "ERROR: ${DEPLOY_DIR}/.env is missing on the server"
echo "Create it from fe/web/.env.example (or fe/web/.env.production) before deploying."
exit 1
fi
export IMAGE_TAG
export ADMIN_IMAGE
export TELLER_IMAGE
export CUSTOMER_IMAGE
docker compose -f docker-compose.yml pull admin teller customer
docker compose -f docker-compose.yml up -d --remove-orphans
echo "✓ Deployed IMAGE_TAG=${IMAGE_TAG}"
- name: Verify deployment
run: |
set -e
cd "${DEPLOY_DIR}"
echo "Waiting for services to start..."
sleep 8
for SERVICE in admin teller customer; do
STATUS=$(docker compose -f docker-compose.yml ps --status running --format '{{.Name}}' "$SERVICE" 2>/dev/null || true)
if [ -z "$STATUS" ]; then
echo "ERROR: ${SERVICE} is not running"
docker compose -f docker-compose.yml ps
docker compose -f docker-compose.yml logs --tail=50 "$SERVICE" || true
exit 1
fi
echo "✓ ${SERVICE} is running (${STATUS})"
done
ADMIN_HOST_PORT=$(grep -E '^ADMIN_HOST_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
TELLER_HOST_PORT=$(grep -E '^TELLER_HOST_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
CUSTOMER_HOST_PORT=$(grep -E '^CUSTOMER_HOST_PORT=' .env 2>/dev/null | cut -d= -f2- | tr -d '"' || true)
ADMIN_HOST_PORT=${ADMIN_HOST_PORT:-5000}
TELLER_HOST_PORT=${TELLER_HOST_PORT:-3001}
CUSTOMER_HOST_PORT=${CUSTOMER_HOST_PORT:-3000}
check_http() {
NAME=$1
PORT=$2
for i in 1 2 3 4 5 6; do
CODE=$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${PORT}/" || echo "000")
if [ "${CODE}" != "000" ]; then
echo "✓ ${NAME} is responding on port ${PORT} (HTTP ${CODE})"
return 0
fi
echo "Waiting for ${NAME}... attempt ${i}/6"
sleep 3
done
echo "ERROR: ${NAME} is not responding on port ${PORT}"
return 1
}
check_http "admin" "${ADMIN_HOST_PORT}"
check_http "teller" "${TELLER_HOST_PORT}"
check_http "customer" "${CUSTOMER_HOST_PORT}"
-7
View File
@@ -51,10 +51,3 @@ The backend of the queue management system will be developed using Java's Spring
- [Ahmed Ljubuncic](https://github.com/aljubuncic) - [Ahmed Ljubuncic](https://github.com/aljubuncic)
- [Vedran Mujic](https://github.com/vmujic1) - [Vedran Mujic](https://github.com/vmujic1)
- [Amar Tahirovic](https://github.com/amarderschrecklicher) - [Amar Tahirovic](https://github.com/amarderschrecklicher)
## API ENV GUIDE
VITE_API_URL: API for teller and admin (also customer fallback in CI)
NEXT_PUBLIC_API_URL: API for customer
VITE_BRANCH_QR_BASE_URL: public customer base URL encoded into admin branch QR
Match these in Gitea secrets and in `fe/web/.env` on the deploy host (ports/images only matter at deploy; API URLs matter at image build).
-2
View File
@@ -1,2 +0,0 @@
[x] create qr and teller account
[x] increase size of gold display
-10
View File
@@ -1,10 +0,0 @@
target/
uploads/
.git/
.gitignore
.idea/
*.iml
.vscode/
*.md
.env
.DS_Store
-14
View File
@@ -1,14 +0,0 @@
# Host ports (change these if they collide on your single server)
BACKEND_HOST_PORT=8080
# Published for DBeaver/tools (container still uses 3306 internally)
MYSQL_HOST_PORT=3307
# MySQL
# Quote if the password contains special chars like ; # $
MYSQL_ROOT_PASSWORD="password"
MYSQL_DATABASE=qms
# App
JWT_SECRET_KEY=change-me-to-a-long-random-secret
GOOGLE_CLIENT_ID=dummy-google-client-id
NOTIFICATIONS_MOCK=true
-13
View File
@@ -1,13 +0,0 @@
# Host ports (change these if they collide on your single server)
BACKEND_HOST_PORT=8080
# Published for DBeaver/tools (container still uses 3306 internally)
MYSQL_HOST_PORT=3307
# MySQL
MYSQL_ROOT_PASSWORD="R8qjjBJDg9NtL2IwcnoFMkn8pktwAZ;wsl"
MYSQL_DATABASE=qms
# App
JWT_SECRET_KEY=a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF
GOOGLE_CLIENT_ID=dummy-google-client-id
NOTIFICATIONS_MOCK=true
-3
View File
@@ -34,6 +34,3 @@ build/
### Uploads ### ### Uploads ###
uploads/ uploads/
### Docker / env ###
.env
+3 -27
View File
@@ -1,27 +1,3 @@
# Build stage FROM openjdk:17-jdk-alpine
# Non-alpine tags are multi-arch (amd64 + arm64). Alpine Maven often has no Mac/ARM image. COPY target/bbqms-0.0.1-SNAPSHOT.jar ./app.jar
FROM maven:3.9-eclipse-temurin-17 AS build ENTRYPOINT ["java", "-jar", "/app.jar"]
WORKDIR /app
COPY pom.xml .
COPY src ./src
# Use image mvn (not ./mvnw). Official maven image sets MAVEN_CONFIG=/root/.m2,
# which breaks the wrapper with: Unknown lifecycle phase "/root/.m2"
RUN mvn -q -DskipTests package
# Runtime stage
FROM eclipse-temurin:17-jre
WORKDIR /app
RUN groupadd -r qms && useradd -r -g qms qms \
&& mkdir -p /app/uploads/ads \
&& chown -R qms:qms /app
COPY --from=build /app/target/bbqms-0.0.1-SNAPSHOT.jar app.jar
USER qms
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+4 -33
View File
@@ -1,38 +1,9 @@
## Build instructions (local) ## Build instructions
- Enter your database details in the **src/main/resources/application.yml** file (or use env vars) - Enter your database details in the **src/main/resources/application.yml** file
- Through IntelliJ simply click on the run button - Through IntelliJ simply click on the run button
- Or through console - Or through console
- `./mvnw dependency:resolve` - ./mvnw dependency:resolve
- `./mvnw spring-boot:run` - ./mvnw spring-boot:run
##### **NOTE:** requires Java 17 and MySQL 8 ##### **NOTE:** requires Java 17 and MySQL 8
## Docker deployment (single server)
From the `be/` directory:
```bash
cp .env.example .env
# Edit .env if ports 8080 / 3306 are already in use on the server
docker compose up -d --build
```
Services and default host ports:
| Service | Container | Host port (configurable) |
|----------|-----------|---------------------------|
| Backend | qms-backend | `BACKEND_HOST_PORT`**8080** |
| MySQL | qms-mysql | `MYSQL_HOST_PORT`**3306** |
API base URL on the server: `http://<server-ip>:8080`
Useful commands:
```bash
docker compose ps
docker compose logs -f backend
docker compose down # stop containers (keeps DB volume)
docker compose down -v # stop and delete DB/uploads volumes
```
+4 -20
View File
@@ -2,34 +2,18 @@ services:
mysql: mysql:
image: mysql:8 image: mysql:8
container_name: qms-mysql container_name: qms-mysql
restart: unless-stopped
ports: ports:
- "3306:3306" - "3306:3306"
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password} MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms} MYSQL_DATABASE: qms
volumes: volumes:
- qms-mysql-data:/var/lib/mysql - qms-mysql-data:/var/lib/mysql
healthcheck: healthcheck:
test: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-ppassword"]
[
"CMD",
"mysqladmin",
"ping",
"-h",
"localhost",
"-p${MYSQL_ROOT_PASSWORD:-password}",
]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 20 retries: 10
start_period: 20s
networks:
- qms-net
volumes: volumes:
qms-mysql-data: qms-mysql-data:
networks:
qms-net:
driver: bridge
-64
View File
@@ -1,64 +0,0 @@
services:
mysql:
image: mysql:8
container_name: qms-mysql
restart: unless-stopped
ports:
- "${MYSQL_HOST_PORT:-3307}:3306"
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms}
volumes:
- qms-mysql-data:/var/lib/mysql
healthcheck:
test:
[
"CMD",
"mysqladmin",
"ping",
"-h",
"localhost",
"-p${MYSQL_ROOT_PASSWORD:-password}",
]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s
networks:
- qms-net
backend:
image: ${BACKEND_IMAGE:-qms-backend}:${IMAGE_TAG:-local}
build:
context: .
dockerfile: Dockerfile
container_name: qms-backend
restart: unless-stopped
ports:
# Host port can be changed via BACKEND_HOST_PORT in .env (default 8080)
- "${BACKEND_HOST_PORT:-8080}:8080"
environment:
SERVER_PORT: 8080
# Use Docker service name "mysql", not localhost
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/${MYSQL_DATABASE:-qms}?allowPublicKeyRetrieval=true&useSSL=false
SPRING_DATASOURCE_USERNAME: root
SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF}
ADS_UPLOAD_DIR: /app/uploads/ads
NOTIFICATIONS_MOCK: ${NOTIFICATIONS_MOCK:-true}
GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-dummy-google-client-id}
volumes:
- qms-uploads:/app/uploads
depends_on:
mysql:
condition: service_healthy
networks:
- qms-net
volumes:
qms-mysql-data:
qms-uploads:
networks:
qms-net:
driver: bridge
@@ -33,14 +33,13 @@ public class DefaultAdminService implements AdminService {
private final RoleService roleService; private final RoleService roleService;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AuthService authService; private final AuthService authService;
public DefaultAdminService(final UserRepository userRepository, public DefaultAdminService(final UserRepository userRepository,
final UserService userService, final UserService userService,
final TwoFactorService twoFactorService, final TwoFactorService twoFactorService,
final TenantService tenantService, final TenantService tenantService,
final RoleService roleService, final RoleService roleService,
final PasswordEncoder passwordEncoder, final PasswordEncoder passwordEncoder,
final AuthService authService) { final AuthService authService) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.userService = userService; this.userService = userService;
this.twoFactorService = twoFactorService; this.twoFactorService = twoFactorService;
@@ -51,7 +50,7 @@ public class DefaultAdminService implements AdminService {
} }
@Override @Override
public List<User> findUsersByCode(final String tenantCode, final String roleName) { public List<User> findUsersByCode(final String tenantCode, final String roleName){
final Set<RoleName> roleNameSet = Set.of(RoleName.valueOf(roleName)); final Set<RoleName> roleNameSet = Set.of(RoleName.valueOf(roleName));
return this.userRepository.findAllByTenant_CodeAndRoles_NameIn(tenantCode, roleNameSet); return this.userRepository.findAllByTenant_CodeAndRoles_NameIn(tenantCode, roleNameSet);
} }
@@ -14,7 +14,6 @@ import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@RestController @RestController
@@ -31,8 +30,7 @@ public class AdminController {
@PostMapping("/{code}") @PostMapping("/{code}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity<List<UserDto>> getUsers(@RequestBody RoleRequest request, public ResponseEntity getUsers(@RequestBody RoleRequest request, @PathVariable(name = "code") final String tenantCode) {
@PathVariable(name = "code") final String tenantCode) {
RoleName roleName; RoleName roleName;
try { try {
roleName = RoleName.valueOf(request.roleName); roleName = RoleName.valueOf(request.roleName);
@@ -41,7 +39,7 @@ public class AdminController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if (this.authService.canOnlyCRUDUser(roleName)) { if(this.authService.canOnlyCRUDUser(roleName)){
logger.warn("Only super admin can read admins"); logger.warn("Only super admin can read admins");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -54,7 +52,8 @@ public class AdminController {
return ResponseEntity.ok().body( return ResponseEntity.ok().body(
this.adminService.findUsersByCode(tenantCode, request.roleName).stream() this.adminService.findUsersByCode(tenantCode, request.roleName).stream()
.map(UserDto::fromEntity) .map(UserDto::fromEntity)
.collect(Collectors.toList())); .collect(Collectors.toList())
);
} catch (EntityNotFoundException e) { } catch (EntityNotFoundException e) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -62,8 +61,7 @@ public class AdminController {
@PostMapping("/{code}/user") @PostMapping("/{code}/user")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity<UserDto> addUser(@RequestBody final AdminRequest request, public ResponseEntity addUser(@RequestBody final AdminRequest request, @PathVariable(name = "code") final String tenantCode) throws AuthException {
@PathVariable(name = "code") final String tenantCode) throws AuthException {
RoleName roleName; RoleName roleName;
try { try {
roleName = RoleName.valueOf(request.roleName); roleName = RoleName.valueOf(request.roleName);
@@ -72,7 +70,7 @@ public class AdminController {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
if (this.authService.canOnlyCRUDUser(roleName)) { if(this.authService.canOnlyCRUDUser(roleName)){
logger.warn("Only super admin can add admin"); logger.warn("Only super admin can add admin");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -97,8 +95,7 @@ public class AdminController {
@PutMapping("/{code}/user/{userId}") @PutMapping("/{code}/user/{userId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity<SimpleMessageDto> updateAdmin(@RequestBody final UserDto request, public ResponseEntity updateAdmin(@RequestBody final UserDto request, @PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
logger.warn("Admin does not belong to the specified tenant"); logger.warn("Admin does not belong to the specified tenant");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
@@ -115,8 +112,7 @@ public class AdminController {
@DeleteMapping("/{code}/user/{userId}") @DeleteMapping("/{code}/user/{userId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
public ResponseEntity<SimpleMessageDto> removeAdmin(@PathVariable(name = "code") final String tenantCode, public ResponseEntity removeAdmin(@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
@PathVariable(name = "userId") final long adminId) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
logger.warn("Admin does not belong to the specified tenant"); logger.warn("Admin does not belong to the specified tenant");
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
@@ -134,6 +130,6 @@ public class AdminController {
public record AdminRequest(String email, String password, String roleName) { public record AdminRequest(String email, String password, String roleName) {
} }
public record RoleRequest(String roleName) { public record RoleRequest(String roleName){
} }
} }
@@ -30,13 +30,13 @@ public class AdvertisementController {
private final AuthService authService; private final AuthService authService;
public AdvertisementController(final AdvertisementService advertisementService, public AdvertisementController(final AdvertisementService advertisementService,
final AuthService authService) { final AuthService authService) {
this.advertisementService = advertisementService; this.advertisementService = advertisementService;
this.authService = authService; this.authService = authService;
} }
@GetMapping("/media/{adId}") @GetMapping("/media/{adId}")
public ResponseEntity<Resource> streamMedia(@PathVariable final long adId) { public ResponseEntity streamMedia(@PathVariable final long adId) {
try { try {
final Advertisement advertisement = this.advertisementService.findById(adId); final Advertisement advertisement = this.advertisementService.findById(adId);
final Resource resource = this.advertisementService.loadMedia(adId); final Resource resource = this.advertisementService.loadMedia(adId);
@@ -64,11 +64,11 @@ public class AdvertisementController {
@PostMapping("/{tenantCode}") @PostMapping("/{tenantCode}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity createAdvertisement(@PathVariable final String tenantCode, public ResponseEntity createAdvertisement(@PathVariable final String tenantCode,
@RequestParam("file") final MultipartFile file, @RequestParam("file") final MultipartFile file,
@RequestParam(value = "title", required = false) final String title, @RequestParam(value = "title", required = false) final String title,
@RequestParam(value = "durationSeconds", required = false) final Integer durationSeconds, @RequestParam(value = "durationSeconds", required = false) final Integer durationSeconds,
@RequestParam(value = "sortOrder", required = false) final Integer sortOrder, @RequestParam(value = "sortOrder", required = false) final Integer sortOrder,
@RequestParam(value = "active", required = false) final Boolean active) { @RequestParam(value = "active", required = false) final Boolean active) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -80,7 +80,8 @@ public class AdvertisementController {
title, title,
durationSeconds, durationSeconds,
sortOrder, sortOrder,
active); active
);
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created)); return ResponseEntity.ok().body(AdvertisementDto.fromEntity(created));
} catch (final Exception exception) { } catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage())); return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
@@ -107,8 +108,8 @@ public class AdvertisementController {
@PutMapping("/{tenantCode}/{adId}") @PutMapping("/{tenantCode}/{adId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity updateAdvertisement(@PathVariable final String tenantCode, public ResponseEntity updateAdvertisement(@PathVariable final String tenantCode,
@PathVariable final long adId, @PathVariable final long adId,
@RequestBody final AdvertisementUpdateRequest request) { @RequestBody final AdvertisementUpdateRequest request) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -120,7 +121,8 @@ public class AdvertisementController {
request.title(), request.title(),
request.durationSeconds(), request.durationSeconds(),
request.sortOrder(), request.sortOrder(),
request.active()); request.active()
);
return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated)); return ResponseEntity.ok().body(AdvertisementDto.fromEntity(updated));
} catch (final Exception exception) { } catch (final Exception exception) {
return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage())); return ResponseEntity.badRequest().body(new SimpleMessageDto(exception.getMessage()));
@@ -130,7 +132,7 @@ public class AdvertisementController {
@DeleteMapping("/{tenantCode}/{adId}") @DeleteMapping("/{tenantCode}/{adId}")
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')") @PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
public ResponseEntity deleteAdvertisement(@PathVariable final String tenantCode, public ResponseEntity deleteAdvertisement(@PathVariable final String tenantCode,
@PathVariable final long adId) { @PathVariable final long adId) {
if (!this.authService.canChangeTenant(tenantCode)) { if (!this.authService.canChangeTenant(tenantCode)) {
return ResponseEntity.badRequest().build(); return ResponseEntity.badRequest().build();
} }
@@ -144,8 +146,8 @@ public class AdvertisementController {
} }
public record AdvertisementUpdateRequest(String title, public record AdvertisementUpdateRequest(String title,
Integer durationSeconds, Integer durationSeconds,
Integer sortOrder, Integer sortOrder,
Boolean active) { Boolean active) {
} }
} }
+7 -10
View File
@@ -1,6 +1,3 @@
server:
port: ${SERVER_PORT:8080}
spring: spring:
application: application:
name: qms name: qms
@@ -9,9 +6,9 @@ spring:
hibernate: hibernate:
ddl-auto: none ddl-auto: none
datasource: datasource:
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/qms?allowPublicKeyRetrieval=true&useSSL=false} url: jdbc:mysql://localhost:3306/qms
username: ${SPRING_DATASOURCE_USERNAME:root} username: root
password: ${SPRING_DATASOURCE_PASSWORD:password} password: password
servlet: servlet:
multipart: multipart:
max-file-size: 100MB max-file-size: 100MB
@@ -21,14 +18,14 @@ spring:
client: client:
registration: registration:
google: google:
client-id: ${GOOGLE_CLIENT_ID:dummy-google-client-id} client-id: dummy-google-client-id
flyway: flyway:
schemas: qms schemas: qms
jwt: jwt:
header-title: Authorization header-title: Authorization
token-prefix: Bearer token-prefix: Bearer
secret-key: ${JWT_SECRET_KEY:a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF} secret-key: a68uiaDQ0V3iLjF4DqMuS13GAVwkut55dlFbGCLyXTF
authorities-key: USER_AUTHORITIES authorities-key: USER_AUTHORITIES
token-validity-time: PT30M token-validity-time: PT30M
tfa: tfa:
@@ -38,6 +35,6 @@ tenancy:
default-code: DFLT default-code: DFLT
notifications: notifications:
expo-url: https://exp.host/--/api/v2/push/send expo-url: https://exp.host/--/api/v2/push/send
mock: ${NOTIFICATIONS_MOCK:true} mock: true
ads: ads:
upload-dir: ${ADS_UPLOAD_DIR:uploads/ads} upload-dir: uploads/ads
-15
View File
@@ -1,15 +0,0 @@
# Host ports (nginx terminates TLS on 443 and proxies to these)
ADMIN_HOST_PORT=5000
TELLER_HOST_PORT=3001
CUSTOMER_HOST_PORT=3000
VITE_API_URL=https://qms-api.erahn.com.my
NEXT_PUBLIC_API_URL=https://qms-api.erahn.com.my
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
QR_TOKEN_SECRET=qms-branch-qr-v1
ADMIN_IMAGE=git.koppkb.com/kopkb/qms-fe-admin
TELLER_IMAGE=git.koppkb.com/kopkb/qms-fe-teller
CUSTOMER_IMAGE=git.koppkb.com/kopkb/qms-fe-customer
IMAGE_TAG=local
-15
View File
@@ -1,15 +0,0 @@
ADMIN_HOST_PORT=5000
TELLER_HOST_PORT=3001
CUSTOMER_HOST_PORT=3000
VITE_API_URL=https://qms-api.erahn.com.my
NEXT_PUBLIC_API_URL=https://qms-api.erahn.com.my
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
VITE_GOLD_PRICE_URL=https://apiujrah.erahn.com.my/api/harga_emas
QR_TOKEN_SECRET=qms-branch-qr-v1
ADMIN_IMAGE=git.koppkb.com/kopkb/qms-fe-admin
TELLER_IMAGE=git.koppkb.com/kopkb/qms-fe-teller
CUSTOMER_IMAGE=git.koppkb.com/kopkb/qms-fe-customer
IMAGE_TAG=local
-15
View File
@@ -1,15 +0,0 @@
ADMIN_HOST_PORT=5000
TELLER_HOST_PORT=3001
CUSTOMER_HOST_PORT=3000
VITE_API_URL=https://qms-api.erahn.com.my
NEXT_PUBLIC_API_URL=https://qms-api.erahn.com.my
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
VITE_GOLD_PRICE_URL=https://apiujrah.erahn.com.my/api/harga_emas
QR_TOKEN_SECRET=qms-branch-qr-v1
ADMIN_IMAGE=git.koppkb.com/kopkb/qms-fe-admin
TELLER_IMAGE=git.koppkb.com/kopkb/qms-fe-teller
CUSTOMER_IMAGE=git.koppkb.com/kopkb/qms-fe-customer
IMAGE_TAG=local
-3
View File
@@ -1,3 +0,0 @@
.env
!.env.example
!.env.production
-8
View File
@@ -1,8 +0,0 @@
node_modules
dist
.git
.gitignore
*.md
.env
.env.*
.DS_Store
-2
View File
@@ -1,2 +0,0 @@
VITE_API_URL=http://localhost:8080
VITE_BRANCH_QR_BASE_URL=http://localhost:3000
-2
View File
@@ -1,2 +0,0 @@
VITE_API_URL=https://qms-api.erahn.com.my
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
-2
View File
@@ -1,2 +0,0 @@
VITE_API_URL=https://qms-api.erahn.com.my
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
+6 -17
View File
@@ -1,21 +1,10 @@
# Build stage FROM node:21-alpine AS build
FROM node:20-bookworm AS build WORKDIR /admin-app
WORKDIR /app COPY package*.json .
RUN npm install
ARG VITE_API_URL=http://localhost:8080
ARG VITE_BRANCH_QR_BASE_URL=http://localhost:3000
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_BRANCH_QR_BASE_URL=$VITE_BRANCH_QR_BASE_URL
COPY package.json package-lock.json ./
RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# Runtime stage — static SPA EXPOSE 5001
FROM nginx:alpine CMD ["npm", "run", "preview"]
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-10
View File
@@ -1,10 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+3 -304
View File
@@ -12,7 +12,6 @@
"bootstrap": "^5.3.3", "bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3", "bootstrap-icons": "^1.11.3",
"formik": "^2.4.5", "formik": "^2.4.5",
"qrcode": "^1.5.4",
"react": "^18.2.0", "react": "^18.2.0",
"react-bootstrap": "^2.10.2", "react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
@@ -23,7 +22,6 @@
"yup": "^1.4.0" "yup": "^1.4.0"
}, },
"devDependencies": { "devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.64", "@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21", "@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.2.1",
@@ -1297,31 +1295,11 @@
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA==" "integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA=="
}, },
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.11", "version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==" "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
}, },
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "18.2.66", "version": "18.2.66",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz",
@@ -1433,6 +1411,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -1716,15 +1695,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001597", "version": "1.0.30001597",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz",
@@ -1764,17 +1734,6 @@
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -1904,15 +1863,6 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1969,12 +1919,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": { "node_modules/doctrine": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -2010,12 +1954,6 @@
"integrity": "sha512-iWgEEvREL4GTXXHKohhh33+6Y8XkPI5eHihDmm8zUk5Zo7HICEW+wI/j5kJ2tbuNUCXJ/sNXa03ajW635DiJXA==", "integrity": "sha512-iWgEEvREL4GTXXHKohhh33+6Y8XkPI5eHihDmm8zUk5Zo7HICEW+wI/j5kJ2tbuNUCXJ/sNXa03ajW635DiJXA==",
"dev": true "dev": true
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/es-abstract": { "node_modules/es-abstract": {
"version": "1.23.0", "version": "1.23.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.0.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.0.tgz",
@@ -2694,15 +2632,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": { "node_modules/get-intrinsic": {
"version": "1.2.4", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
@@ -3113,15 +3042,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": { "node_modules/is-generator-function": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
@@ -3711,15 +3631,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3736,6 +3647,7 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -3770,15 +3682,6 @@
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"dev": true "dev": true
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": { "node_modules/possible-typed-array-names": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -3861,23 +3764,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -4095,21 +3981,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "2.0.0-next.5", "version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -4324,12 +4195,6 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": { "node_modules/set-function-length": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -4410,20 +4275,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string.prototype.matchall": { "node_modules/string.prototype.matchall": {
"version": "4.0.10", "version": "4.0.10",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz",
@@ -4493,6 +4344,7 @@
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1" "ansi-regex": "^5.0.1"
}, },
@@ -4697,13 +4549,6 @@
"react": ">=15.0.0" "react": ">=15.0.0"
} }
}, },
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.0.13", "version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
@@ -4889,12 +4734,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": { "node_modules/which-typed-array": {
"version": "1.1.15", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz",
@@ -4914,158 +4753,18 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi/node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/wrap-ansi/node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"dev": true "dev": true
}, },
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true "dev": true
}, },
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+6 -8
View File
@@ -1,11 +1,9 @@
export const SERVER_URL = export const SERVER_URL = 'http://localhost:8080';
import.meta.env.VITE_API_URL ?? 'http://localhost:8080';
/** Public base URL encoded into branch QR codes (ManageBranchesScreen only). */ export const CUSTOMER_APP_URL =
export const BRANCH_QR_BASE_URL = import.meta.env.VITE_CUSTOMER_APP_URL ?? 'http://localhost:3000';
import.meta.env.VITE_BRANCH_QR_BASE_URL ?? 'http://localhost:3000';
export const ROLES = { export const ROLES = {
ROLE_SUPER_ADMIN: 'ROLE_SUPER_ADMIN', ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
ROLE_BRANCH_ADMIN: 'ROLE_BRANCH_ADMIN', ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
}; }
+2 -2
View File
@@ -1,5 +1,5 @@
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import { BRANCH_QR_BASE_URL } from '../constants.js'; import { CUSTOMER_APP_URL } from '../constants.js';
const DEFAULT_QR_SECRET = 'qms-branch-qr-v1'; const DEFAULT_QR_SECRET = 'qms-branch-qr-v1';
@@ -54,7 +54,7 @@ export function decodeBranchQrToken(token, secret = getQrSecret()) {
} }
export function getBranchTicketUrl(tenantCode, branchId) { export function getBranchTicketUrl(tenantCode, branchId) {
const base = BRANCH_QR_BASE_URL.replace(/\/$/, ''); const base = CUSTOMER_APP_URL.replace(/\/$/, '');
const token = encodeBranchQrToken(tenantCode, branchId); const token = encodeBranchQrToken(tenantCode, branchId);
return `${base}/q/${token}`; return `${base}/q/${token}`;
} }
-10
View File
@@ -1,10 +0,0 @@
node_modules
.next
.git
.gitignore
*.md
.env
.env.*
!.env.example
.DS_Store
tsconfig.tsbuildinfo
-35
View File
@@ -1,35 +0,0 @@
# Build stage
FROM node:20-bookworm AS build
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@9 --activate
ARG NEXT_PUBLIC_API_URL=http://localhost:8080
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# Runtime stage
FROM node:20-bookworm-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=build /app/public ./public
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
@@ -22,9 +22,6 @@ type Props = {
initialBranchId?: number; initialBranchId?: number;
}; };
/** Prevents spam of "Dapatkan tiket lain" right after getting a number. */
const ANOTHER_TICKET_COOLDOWN_MS = 10_000;
function pickLatestTicket( function pickLatestTicket(
tickets: Ticket[], tickets: Ticket[],
branchId?: number branchId?: number
@@ -64,27 +61,8 @@ export default function CustomerTicketFlow({
const [ticket, setTicket] = useState<Ticket | null>(null); const [ticket, setTicket] = useState<Ticket | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [cooldownLeftMs, setCooldownLeftMs] = useState(0);
const bootstrapped = useRef(false); const bootstrapped = useRef(false);
useEffect(() => {
if (step !== "ticket" || !ticket) {
setCooldownLeftMs(0);
return;
}
const tick = () => {
const elapsed = Date.now() - new Date(ticket.createdAt).getTime();
setCooldownLeftMs(
Math.max(0, ANOTHER_TICKET_COOLDOWN_MS - elapsed)
);
};
tick();
const id = window.setInterval(tick, 250);
return () => window.clearInterval(id);
}, [step, ticket]);
useEffect(() => { useEffect(() => {
if (bootstrapped.current) return; if (bootstrapped.current) return;
bootstrapped.current = true; bootstrapped.current = true;
@@ -303,9 +281,6 @@ export default function CustomerTicketFlow({
? "Pilih perkhidmatan untuk mendapatkan nombor." ? "Pilih perkhidmatan untuk mendapatkan nombor."
: "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah."; : "Imbas QR code di branch anda, atau masukkan kod syarikat di bawah.";
const canGetAnotherTicket = cooldownLeftMs <= 0;
const cooldownSeconds = Math.ceil(cooldownLeftMs / 1000);
return ( return (
<main className="mx-auto flex min-h-full w-full max-w-xl flex-col gap-8 px-6 py-12"> <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"> <header className="space-y-2">
@@ -448,7 +423,7 @@ export default function CustomerTicketFlow({
{step === "ticket" && ticket ? ( {step === "ticket" && ticket ? (
<section className="space-y-6 rounded-md border border-zinc-200 bg-white px-6 py-8 text-center"> <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"> <p className="text-sm tracking-wide text-zinc-500 uppercase">
Nombor giliran anda Nombor tiket anda
</p> </p>
<p className="text-6xl font-semibold tracking-tight text-zinc-900"> <p className="text-6xl font-semibold tracking-tight text-zinc-900">
{ticket.number} {ticket.number}
@@ -457,36 +432,12 @@ export default function CustomerTicketFlow({
<p>{ticket.service.name}</p> <p>{ticket.service.name}</p>
<p>{ticket.branch.name}</p> <p>{ticket.branch.name}</p>
</div> </div>
<div
className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-left text-sm text-amber-950"
role="status"
>
<p className="font-semibold">Anda sudah mempunyai nombor giliran.</p>
<p className="mt-1 text-amber-900">
Sila tunggu sehingga nombor anda dipanggil.
</p>
{!canGetAnotherTicket ? (
<p className="mt-2 font-medium tabular-nums text-amber-950">
Tiket lain boleh diambil dalam {cooldownSeconds}s
</p>
) : null}
</div>
<button <button
type="button" type="button"
onClick={resetFlow} onClick={resetFlow}
disabled={!canGetAnotherTicket || loading} className="w-full rounded-md bg-zinc-900 px-4 py-3 text-sm font-medium text-white"
aria-disabled={!canGetAnotherTicket || loading}
className={
canGetAnotherTicket
? "w-full rounded-md border border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-700 transition hover:border-zinc-500 hover:bg-zinc-50 disabled:opacity-60"
: "w-full cursor-not-allowed rounded-md bg-zinc-200 px-4 py-3 text-sm font-medium text-zinc-500"
}
> >
{canGetAnotherTicket Dapatkan tiket lain
? "Dapatkan nombor giliran lain"
: `Tunggu ${cooldownSeconds}s…`}
</button> </button>
</section> </section>
) : null} ) : null}
-1
View File
@@ -2,7 +2,6 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
devIndicators: false, devIndicators: false,
output: "standalone",
}; };
export default nextConfig; export default nextConfig;
+1 -7
View File
@@ -3,7 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --webpack", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
@@ -22,11 +22,5 @@
"eslint-config-next": "16.2.10", "eslint-config-next": "16.2.10",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5" "typescript": "^5"
},
"pnpm": {
"ignoredBuiltDependencies": [
"sharp",
"unrs-resolver"
]
} }
} }
+3
View File
@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver
-40
View File
@@ -1,40 +0,0 @@
services:
admin:
image: ${ADMIN_IMAGE:-qms-fe-admin}:${IMAGE_TAG:-local}
build:
context: ./admin-app
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8080}
VITE_BRANCH_QR_BASE_URL: ${VITE_BRANCH_QR_BASE_URL:-http://localhost:3000}
container_name: qms-fe-admin
restart: unless-stopped
ports:
- "${ADMIN_HOST_PORT:-5000}:80"
teller:
image: ${TELLER_IMAGE:-qms-fe-teller}:${IMAGE_TAG:-local}
build:
context: ./teller-app
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-http://localhost:8080}
VITE_GOLD_PRICE_URL: ${VITE_GOLD_PRICE_URL:-https://apiujrah.erahn.com.my/api/harga_emas}
container_name: qms-fe-teller
restart: unless-stopped
ports:
- "${TELLER_HOST_PORT:-3001}:80"
customer:
image: ${CUSTOMER_IMAGE:-qms-fe-customer}:${IMAGE_TAG:-local}
build:
context: ./customer-app
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:8080}
container_name: qms-fe-customer
restart: unless-stopped
ports:
- "${CUSTOMER_HOST_PORT:-3000}:3000"
environment:
QR_TOKEN_SECRET: ${QR_TOKEN_SECRET:-qms-branch-qr-v1}
-8
View File
@@ -1,8 +0,0 @@
node_modules
dist
.git
.gitignore
*.md
.env
.env.*
.DS_Store
+6 -18
View File
@@ -1,22 +1,10 @@
# Build stage FROM node:21-alpine AS build
FROM node:20-bookworm AS build WORKDIR /teller-app
WORKDIR /app COPY package*.json .
RUN npm install
ARG VITE_API_URL=http://localhost:8080
ARG VITE_GOLD_PRICE_URL=https://apiujrah.erahn.com.my/api/harga_emas
ENV VITE_API_URL=$VITE_API_URL
ENV VITE_GOLD_PRICE_URL=$VITE_GOLD_PRICE_URL
COPY package.json package-lock.json ./
RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
# Runtime stage — static SPA EXPOSE 3000
FROM nginx:alpine CMD ["npm", "run", "preview"]
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
-10
View File
@@ -1,10 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+4 -2
View File
@@ -1,6 +1,8 @@
export const SERVER_URL = import.meta.env.VITE_API_URL || 'http://localhost:8080'; 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 GOLD_PRICE_URL =
import.meta.env.VITE_GOLD_PRICE_URL ??
'https://apiujrah.erahn.com.my/api/harga_emas';
export const ROLES = { export const ROLES = {
ROLE_USER: 'ROLE_USER', ROLE_USER: 'ROLE_USER',
@@ -76,41 +76,22 @@ export default function AdCarousel({ tenantCode }) {
const video = videoRef.current; const video = videoRef.current;
if (!video || currentAd?.mediaType !== 'VIDEO') return undefined; if (!video || currentAd?.mediaType !== 'VIDEO') return undefined;
let removeUnmuteListener = () => {};
const onEnded = () => goNext(); const onEnded = () => goNext();
const scheduleFallbackAdvance = () => {
clearTimer();
const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
timerRef.current = setTimeout(goNext, durationMs);
};
video.addEventListener('ended', onEnded); video.addEventListener('ended', onEnded);
video.muted = false; video.muted = true;
video.playsInline = true; video.playsInline = true;
const playPromise = video.play(); const playPromise = video.play();
if (playPromise?.catch) { if (playPromise?.catch) {
playPromise.catch(() => { playPromise.catch(() => {
// Unmuted autoplay blocked — keep video playing muted, then unmute on gesture. // Autoplay blocked — advance after durationSeconds fallback
video.muted = true; clearTimer();
const mutedPlay = video.play(); const durationMs = Math.max(5, Number(currentAd.durationSeconds) || 15) * 1000;
if (mutedPlay?.catch) { timerRef.current = setTimeout(goNext, durationMs);
mutedPlay.catch(scheduleFallbackAdvance);
}
const unmute = () => {
video.muted = false;
video.play()?.catch(() => {});
};
window.addEventListener('pointerdown', unmute, { once: true });
removeUnmuteListener = () => window.removeEventListener('pointerdown', unmute);
}); });
} }
return () => { return () => {
video.removeEventListener('ended', onEnded); video.removeEventListener('ended', onEnded);
removeUnmuteListener();
}; };
}, [currentAd, goNext, clearTimer]); }, [currentAd, goNext, clearTimer]);
@@ -146,6 +127,7 @@ export default function AdCarousel({ tenantCode }) {
ref={videoRef} ref={videoRef}
className="branch-display__ad-media" className="branch-display__ad-media"
src={src} src={src}
muted
playsInline playsInline
autoPlay autoPlay
/> />
@@ -15,15 +15,11 @@
--bd-error-bg: #5c1a1a; --bd-error-bg: #5c1a1a;
--bd-error-text: #fecaca; --bd-error-text: #fecaca;
/* Fluid root: scales 720p → 4K; descendants use em so they follow */
font-size: clamp(12px, 0.75vw + 0.55vh, 28px);
position: relative; position: relative;
height: 100vh; height: 100vh;
height: 100dvh;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 0.75em 1em; padding: 1rem 1.5rem 1rem;
background: background:
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(62, 207, 122, 0.12), transparent 55%), 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%), radial-gradient(ellipse 60% 40% at 95% 5%, rgba(240, 208, 96, 0.1), transparent 50%),
@@ -32,37 +28,54 @@
font-family: system-ui, -apple-system, Segoe UI, sans-serif; font-family: system-ui, -apple-system, Segoe UI, sans-serif;
display: grid; display: grid;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
gap: 0.6em; gap: 0.75rem;
overflow: hidden; 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 { .branch-display__header {
margin: 0; margin: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1em; gap: 1rem;
} }
.branch-display__brand { .branch-display__brand {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.85em; gap: 0.85rem;
min-width: 0; min-width: 0;
} }
.branch-display__logo { .branch-display__logo {
height: clamp(2em, 1.8em + 1vw, 4em); height: clamp(2.5rem, 4.5vw, 3.5rem);
width: auto; width: auto;
flex-shrink: 0; flex-shrink: 0;
border-radius: 0.35em; border-radius: 0.35rem;
object-fit: contain; object-fit: contain;
background: #fff; background: #fff;
} }
.branch-display__eyebrow { .branch-display__eyebrow {
margin: 0; margin: 0;
font-size: 0.75em; font-size: 0.75rem;
letter-spacing: 0.12em; letter-spacing: 0.12em;
text-transform: uppercase; text-transform: uppercase;
color: var(--bd-accent); color: var(--bd-accent);
@@ -71,7 +84,7 @@
.branch-display__title { .branch-display__title {
margin: 0; margin: 0;
font-size: clamp(1.25em, 2vw, 1.75em); font-size: clamp(1.4rem, 2.2vw, 2rem);
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--bd-text); color: var(--bd-text);
@@ -80,17 +93,17 @@
.branch-display__error { .branch-display__error {
margin: 0; margin: 0;
padding: 0.5em 0.75em; padding: 0.5rem 0.75rem;
border-radius: 0.4em; border-radius: 0.4rem;
background: var(--bd-error-bg); background: var(--bd-error-bg);
color: var(--bd-error-text); color: var(--bd-error-text);
font-size: 0.9em; font-size: 0.9rem;
} }
.branch-display__main { .branch-display__main {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); grid-template-columns: minmax(0, 1fr) minmax(340px, 1.05fr);
gap: 0.85em; gap: 0.85rem;
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
@@ -98,7 +111,6 @@
@media (max-width: 960px) { @media (max-width: 960px) {
.branch-display { .branch-display {
height: auto; height: auto;
min-height: 100dvh;
min-height: 100vh; min-height: 100vh;
overflow: auto; overflow: auto;
grid-template-rows: auto; grid-template-rows: auto;
@@ -115,7 +127,7 @@
.branch-display__section--gold { .branch-display__section--gold {
flex: none; flex: none;
min-height: 16em; min-height: 16rem;
} }
} }
@@ -124,7 +136,7 @@
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75em; gap: 0.65rem;
overflow: hidden; overflow: hidden;
} }
@@ -133,23 +145,12 @@
min-height: 0; min-height: 0;
} }
/* Serving block must keep its content height — otherwise cards spill into Giliran */
.branch-display__queue-column > .branch-display__section:first-of-type {
flex: 0 0 auto;
min-height: auto;
overflow: hidden;
}
.branch-display__section--waiting { .branch-display__section--waiting {
flex: 0 1 auto; flex: 0 1 auto;
max-height: 28%; max-height: 28%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden;
position: relative;
z-index: 1;
background: transparent;
} }
.branch-display__section--gold { .branch-display__section--gold {
@@ -157,12 +158,11 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0; min-height: 0;
overflow: hidden;
} }
.branch-display__section-title { .branch-display__section-title {
margin: 0 0 0.45em; margin: 0 0 0.45rem;
font-size: 1.05em; font-size: 0.85rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
@@ -171,16 +171,16 @@
.branch-display__stations { .branch-display__stations {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10em), 1fr)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.7em; gap: 0.55rem;
} }
.branch-display__station-card { .branch-display__station-card {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.3em; gap: 0.2rem;
padding: 0.9em 0.65em; padding: 0.65rem 0.5rem;
border-radius: 0.5em; border-radius: 0.5rem;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%); background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08); box-shadow: inset 0 1px 0 rgba(62, 207, 122, 0.08);
@@ -189,38 +189,18 @@
.branch-display__station-name { .branch-display__station-name {
margin: 0; margin: 0;
font-size: 1.1em; font-size: 0.8rem;
color: var(--bd-muted); color: var(--bd-muted);
} }
.branch-display__ticket-number { .branch-display__ticket-number {
margin: 0; margin: 0;
font-size: clamp(1.75em, min(4.2vw, 8vh), 5.5em); font-size: clamp(1.75rem, 3.2vw, 2.75rem);
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
letter-spacing: -0.03em; letter-spacing: -0.03em;
color: var(--bd-accent-bright); color: var(--bd-accent-bright);
text-shadow: 0 0 1.5em rgba(255, 229, 102, 0.25); text-shadow: 0 0 24px rgba(255, 229, 102, 0.25);
}
/* Short TVs (e.g. 720p): tighten serving cards so sections don't collide */
@media (max-height: 800px) {
.branch-display__station-card {
padding: 0.55em 0.5em;
gap: 0.15em;
}
.branch-display__ticket-number {
font-size: clamp(1.5em, min(3.6vw, 7vh), 3.25em);
}
.branch-display__section-title {
margin-bottom: 0.3em;
}
.branch-display__section--waiting {
max-height: 24%;
}
} }
.branch-display__ticket-number--idle { .branch-display__ticket-number--idle {
@@ -230,7 +210,7 @@
.branch-display__station-service { .branch-display__station-service {
margin: 0; margin: 0;
font-size: 1em; font-size: 0.75rem;
color: var(--bd-muted-strong); color: var(--bd-muted-strong);
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -241,8 +221,8 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 0.75em; gap: 0.75rem;
margin-bottom: 0.4em; margin-bottom: 0.4rem;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -252,14 +232,14 @@
.branch-display__waiting-count { .branch-display__waiting-count {
margin: 0; margin: 0;
font-size: 1.05em; font-size: 0.85rem;
color: var(--bd-accent); color: var(--bd-accent);
} }
.branch-display__empty { .branch-display__empty {
margin: 0; margin: 0;
color: var(--bd-muted); color: var(--bd-muted);
font-size: 1.05em; font-size: 0.9rem;
} }
.branch-display__waiting-list { .branch-display__waiting-list {
@@ -268,7 +248,7 @@
padding: 0; padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.45em; gap: 0.35rem;
flex: 1; flex: 1;
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
@@ -278,24 +258,24 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 0.75em; gap: 0.75rem;
padding: 0.55em 0.85em; padding: 0.4rem 0.7rem;
border-radius: 0.4em; border-radius: 0.4rem;
background: var(--bd-surface); background: var(--bd-surface);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
border-left: 0.2em solid var(--bd-green); border-left: 3px solid var(--bd-green);
flex-shrink: 0; flex-shrink: 0;
} }
.branch-display__waiting-number { .branch-display__waiting-number {
font-size: clamp(1.35em, 2.4vw, 2.5em); font-size: clamp(1.1rem, 1.8vw, 1.5rem);
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--bd-accent); color: var(--bd-accent);
} }
.branch-display__waiting-service { .branch-display__waiting-service {
font-size: 0.85em; font-size: 0.85rem;
color: var(--bd-muted-strong); color: var(--bd-muted-strong);
} }
@@ -303,8 +283,8 @@
min-width: 0; min-width: 0;
min-height: 0; min-height: 0;
height: 100%; height: 100%;
padding: 0.65em; padding: 0.65rem;
border-radius: 0.6em; border-radius: 0.6rem;
background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%); background: linear-gradient(160deg, var(--bd-surface-raised) 0%, var(--bd-surface) 100%);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06); box-shadow: inset 0 1px 0 rgba(240, 208, 96, 0.06);
@@ -317,8 +297,8 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
justify-content: space-between; justify-content: space-between;
gap: 0.75em; gap: 0.75rem;
margin-bottom: 0.4em; margin-bottom: 0.4rem;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -331,7 +311,7 @@
flex: 1; flex: 1;
min-height: 0; min-height: 0;
width: 100%; width: 100%;
border-radius: 0.4em; border-radius: 0.4rem;
overflow: hidden; overflow: hidden;
background: var(--bd-bg); background: var(--bd-bg);
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
@@ -346,8 +326,8 @@
} }
.branch-display__ad-caption { .branch-display__ad-caption {
margin: 0.35em 0 0; margin: 0.35rem 0 0;
font-size: 0.8em; font-size: 0.8rem;
color: var(--bd-muted-strong); color: var(--bd-muted-strong);
text-align: center; text-align: center;
flex-shrink: 0; flex-shrink: 0;
@@ -357,7 +337,7 @@
} }
.branch-display__section--gold .branch-display__waiting-header { .branch-display__section--gold .branch-display__waiting-header {
margin-bottom: 0.35em; margin-bottom: 0.35rem;
} }
.branch-display__gold-table-wrap { .branch-display__gold-table-wrap {
@@ -365,7 +345,7 @@
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
border-radius: 0.5em; border-radius: 0.5rem;
border: 1px solid var(--bd-border); border: 1px solid var(--bd-border);
background: var(--bd-surface); background: var(--bd-surface);
scrollbar-width: none; scrollbar-width: none;
@@ -378,19 +358,19 @@
.branch-display__gold-table { .branch-display__gold-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 1.25em; font-size: 0.9rem;
} }
.branch-display__gold-table th, .branch-display__gold-table th,
.branch-display__gold-table td { .branch-display__gold-table td {
padding: 0.55em 0.75em; padding: 0.45rem 0.65rem;
text-align: left; text-align: left;
border-bottom: 1px solid var(--bd-border); border-bottom: 1px solid var(--bd-border);
line-height: 1.3; line-height: 1.3;
} }
.branch-display__gold-table th { .branch-display__gold-table th {
font-size: 0.72em; font-size: 0.7rem;
letter-spacing: 0.06em; letter-spacing: 0.06em;
text-transform: uppercase; text-transform: uppercase;
color: var(--bd-accent); color: var(--bd-accent);
@@ -29,6 +29,7 @@ export default function BranchDisplayPage() {
const [goldUpdatedAt, setGoldUpdatedAt] = useState(null); const [goldUpdatedAt, setGoldUpdatedAt] = useState(null);
const [goldError, setGoldError] = useState(null); const [goldError, setGoldError] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [soundReady, setSoundReady] = useState(false);
const goldTableWrapRef = useRef(null); const goldTableWrapRef = useRef(null);
const previousServingRef = useRef(null); const previousServingRef = useRef(null);
@@ -191,7 +192,29 @@ export default function BranchDisplayPage() {
); );
return ( return (
<div className="branch-display" onClick={unlockQueueCallSound}> <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"> <header className="branch-display__header">
<div className="branch-display__brand"> <div className="branch-display__brand">
<img <img
@@ -236,7 +259,7 @@ export default function BranchDisplayPage() {
{ticket ? ticket.number : '—'} {ticket ? ticket.number : '—'}
</p> </p>
<p className="branch-display__station-service"> <p className="branch-display__station-service">
{ticket?.service?.name ?? 'Menunggu nombor giliran berikutnya'} {ticket?.service?.name ?? 'Waiting for next'}
</p> </p>
</article> </article>
); );