11 Commits

Author SHA1 Message Date
ISMAIL MASSERAN 318e0c3eb4 DONE: enable sound by default on display tv, disable build images when pushing to main branch
Build Docker Image / build-backend (push) Successful in 12s
Build Docker Image / build-admin (push) Successful in 7s
Build Docker Image / build-teller (push) Successful in 9s
Build Docker Image / build-customer (push) Successful in 5s
2026-07-27 11:50:58 +08:00
ISMAIL MASSERAN 08fe762426 DONE: fix error on gold display is not showing on display tv
Build Docker Image / build-backend (push) Successful in 9s
Build Docker Image / build-admin (push) Successful in 5s
Build Docker Image / build-teller (push) Successful in 21s
Build Docker Image / build-customer (push) Successful in 4s
2026-07-27 09:26:48 +08:00
ISMAIL MASSERAN d0f5d4c979 DONE: update to use ip for the api in frontend
Build Docker Image / build-backend (push) Successful in 6s
Build Docker Image / build-admin (push) Successful in 7s
Build Docker Image / build-teller (push) Successful in 5s
Build Docker Image / build-customer (push) Successful in 12s
2026-07-26 14:08:13 +08:00
ISMAIL MASSERAN 8fd3dbe393 DONE: use ip for frontend but point to domain for api
Build Docker Image / build-backend (push) Successful in 6s
Build Docker Image / build-admin (push) Successful in 45s
Build Docker Image / build-teller (push) Successful in 20s
Build Docker Image / build-customer (push) Successful in 2m2s
2026-07-23 14:59:01 +08:00
ISMAIL MASSERAN 621cf212f5 DONE: cicd pipeline for customer fe
Build Docker Image / build-backend (push) Successful in 5s
Build Docker Image / build-admin (push) Successful in 6s
Build Docker Image / build-teller (push) Successful in 9s
Build Docker Image / build-customer (push) Successful in 2m14s
2026-07-23 12:41:20 +08:00
ISMAIL MASSERAN ea49e140e9 DONE: fix error on build-admin because of outdated package.json missing in lockfile
Build Docker Image / build-backend (push) Successful in 5s
Build Docker Image / build-admin (push) Successful in 51s
Build Docker Image / build-teller (push) Successful in 9s
2026-07-23 12:28:23 +08:00
ISMAIL MASSERAN c55736ee86 DONE: cicd pipeline for frontend that uses ip first
Build Docker Image / build-backend (push) Successful in 16s
Build Docker Image / build-admin (push) Failing after 29s
Build Docker Image / build-teller (push) Successful in 30s
2026-07-23 12:25:11 +08:00
ISMAIL MASSERAN cdb1eb0902 DONE: fix mysql service cannot communicate
Build Docker Image / build-backend (push) Successful in 11s
2026-07-23 12:14:07 +08:00
ISMAIL MASSERAN e367cc1a16 DONE: update backend dockerfile build failed to use official maven instead of lightweight
Build Docker Image / build-backend (push) Successful in 1m28s
2026-07-23 12:01:48 +08:00
ismailmasseran 319057990a DONE: pipeline cicd for backend (#2)
Build Docker Image / build-backend (push) Failing after 22s
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #2
2026-07-23 11:47:55 +08:00
ismailmasseran 85b31e9528 Dev/v1.0 (#1)
Co-authored-by: ISMAIL MASSERAN <topaz@Mac.dlinkrouter.local>
Co-authored-by: ISMAIL MASSERAN <topaz@ISMAILs-Macbook.local>
Reviewed-on: #1
2026-07-23 10:51:04 +08:00
131 changed files with 20649 additions and 438 deletions
+112
View File
@@ -0,0 +1,112 @@
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
@@ -0,0 +1,187 @@
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
@@ -0,0 +1,128 @@
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}"
+22
View File
@@ -6,15 +6,30 @@ This project aims to develop a comprehensive queue management system for bank br
### Teller Interface
The Teller Interface provides functionality for tellers to manage their availability status, allowing them to switch between available and non-available states. This interface will be developed using React.
```bash
pnpm dev --port 3001
```
### Admin Interface
The Admin Interface is designed for administrators to oversee and manage both queues and tellers efficiently. Admins will have the ability to monitor queue statuses, assign tasks to tellers, and make necessary adjustments as needed. This interface will also be developed using React.
```bash
pnpm dev --port 5000
```
### Customer Interface
The Teller Interface provides functionality for tellers to manage their availability status, allowing them to switch between available and non-available states. This interface will be developed using React.
```bash
pnpm dev --port 3000
```
### Mobile App Interface
The Mobile App Interface is catered towards customers visiting the bank branch. Customers can utilize the mobile app to generate a ticket for the queue, allowing them to efficiently manage their time while waiting for service. This interface will be developed using React Native, ensuring compatibility across both iOS and Android devices.
## Backend
The backend of the queue management system will be developed using Java's Spring Boot framework. It will serve as the central component handling communication between the interfaces and managing the underlying data and business logic. The backend will be responsible for tasks such as processing queue requests, managing teller availability, and maintaining queue status updates.
```bash
./mvnw spring-boot:run
```
## Setup Instructions
1. Clone the repository to your local machine.
@@ -36,3 +51,10 @@ The backend of the queue management system will be developed using Java's Spring
- [Ahmed Ljubuncic](https://github.com/aljubuncic)
- [Vedran Mujic](https://github.com/vmujic1)
- [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).
+10
View File
@@ -0,0 +1,10 @@
target/
uploads/
.git/
.gitignore
.idea/
*.iml
.vscode/
*.md
.env
.DS_Store
+14
View File
@@ -0,0 +1,14 @@
# 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
@@ -0,0 +1,13 @@
# 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
+6
View File
@@ -31,3 +31,9 @@ build/
### VS Code ###
.vscode/
### Uploads ###
uploads/
### Docker / env ###
.env
+27 -3
View File
@@ -1,3 +1,27 @@
FROM openjdk:17-jdk-alpine
COPY target/bbqms-0.0.1-SNAPSHOT.jar ./app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]
# Build stage
# Non-alpine tags are multi-arch (amd64 + arm64). Alpine Maven often has no Mac/ARM image.
FROM maven:3.9-eclipse-temurin-17 AS build
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"]
+35 -6
View File
@@ -1,9 +1,38 @@
## Build instructions
## Build instructions (local)
- Enter your database details in the **src/main/resources/application.yml** file
- Through IntelliJ simply click on the run button
- Or through console
- ./mvnw dependency:resolve
- ./mvnw spring-boot:run
- Enter your database details in the **src/main/resources/application.yml** file (or use env vars)
- Through IntelliJ simply click on the run button
- Or through console
- `./mvnw dependency:resolve`
- `./mvnw spring-boot:run`
##### **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
```
+53 -8
View File
@@ -1,19 +1,64 @@
services:
mysql:
image: mysql:8
container_name: bbqms-mysql
container_name: qms-mysql
restart: unless-stopped
ports:
- "3306:3306"
- "${MYSQL_HOST_PORT:-3307}:3306"
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: bbqms
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms}
volumes:
- bbqms-mysql-data:/var/lib/mysql
- qms-mysql-data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-ppassword"]
test:
[
"CMD",
"mysqladmin",
"ping",
"-h",
"localhost",
"-p${MYSQL_ROOT_PASSWORD:-password}",
]
interval: 5s
timeout: 5s
retries: 10
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:
bbqms-mysql-data:
qms-mysql-data:
qms-uploads:
networks:
qms-net:
driver: bridge
+64
View File
@@ -0,0 +1,64 @@
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
@@ -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()
);
}
}
+19 -10
View File
@@ -1,34 +1,43 @@
server:
port: ${SERVER_PORT:8080}
spring:
application:
name: bbqms
name: qms
jpa:
database: mysql
hibernate:
ddl-auto: none
datasource:
url: jdbc:mysql://localhost:3306/bbqms
username: root
password: password
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/qms?allowPublicKeyRetrieval=true&useSSL=false}
username: ${SPRING_DATASOURCE_USERNAME:root}
password: ${SPRING_DATASOURCE_PASSWORD:password}
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
security:
oauth2:
client:
registration:
google:
client-id: dummy-google-client-id
client-id: ${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: ${JWT_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
mock: ${NOTIFICATIONS_MOCK:true}
ads:
upload-dir: ${ADS_UPLOAD_DIR:uploads/ads}
@@ -0,0 +1,19 @@
BEGIN;
CREATE TABLE IF NOT EXISTS advertisement
(
id INTEGER PRIMARY KEY AUTO_INCREMENT,
tenant_id INTEGER NOT NULL,
title VARCHAR(255) NULL,
media_type VARCHAR(32) NOT NULL,
file_name VARCHAR(512) NOT NULL,
content_type VARCHAR(128) NOT NULL,
storage_path VARCHAR(1024) NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
active BOOL NOT NULL DEFAULT TRUE,
duration_seconds INTEGER NOT NULL DEFAULT 10,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT FK_advertisement_tenant FOREIGN KEY (tenant_id) REFERENCES tenant (id)
);
COMMIT;
+3
View File
@@ -10,9 +10,12 @@
"web": "expo start --web"
},
"dependencies": {
"@babel/runtime": "^7.29.7",
"@expo/metro-runtime": "~3.1.3",
"@expo/vector-icons": "^14.0.4",
"@react-native-async-storage/async-storage": "^1.23.1",
"@react-native-community/masked-view": "^0.1.11",
"@react-native/assets-registry": "0.73.1",
"@react-navigation/bottom-tabs": "^6.5.20",
"@react-navigation/native": "^6.1.17",
"@react-navigation/stack": "^6.3.29",
+10492
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,4 +3,4 @@
"compilerOptions": {
"strict": true
}
}
}
+15
View File
@@ -0,0 +1,15 @@
# 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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,15 @@
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
@@ -0,0 +1,3 @@
.env
!.env.example
!.env.production
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
.gitignore
*.md
.env
.env.*
.DS_Store
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:8080
VITE_BRANCH_QR_BASE_URL=http://localhost:3000
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=https://qms-api.erahn.com.my
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=https://qms-api.erahn.com.my
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
+17 -6
View File
@@ -1,10 +1,21 @@
FROM node:21-alpine AS build
WORKDIR /admin-app
COPY package*.json .
RUN npm install
# Build stage
FROM node:20-bookworm AS build
WORKDIR /app
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 . .
RUN npm run build
EXPOSE 5001
CMD ["npm", "run", "preview"]
# Runtime stage — static SPA
FROM nginx:alpine
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;"]
+15 -12
View File
@@ -1,14 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BBQMS Admin App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.ico" />
<link href="https://api.fontshare.com/v2/css?f[]=general-sans@200,300,400,500,600,700&display=swap" rel="stylesheet">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyKOPKB QMS-Admin App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
+304 -3
View File
@@ -12,6 +12,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",
@@ -22,6 +23,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",
@@ -1295,11 +1297,31 @@
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
"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": {
"version": "15.7.11",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
"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": {
"version": "18.2.66",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz",
@@ -1411,7 +1433,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -1695,6 +1716,15 @@
"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": {
"version": "1.0.30001597",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz",
@@ -1734,6 +1764,17 @@
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"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": {
"version": "1.9.3",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
@@ -1863,6 +1904,15 @@
}
}
},
"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": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -1919,6 +1969,12 @@
"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": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
@@ -1954,6 +2010,12 @@
"integrity": "sha512-iWgEEvREL4GTXXHKohhh33+6Y8XkPI5eHihDmm8zUk5Zo7HICEW+wI/j5kJ2tbuNUCXJ/sNXa03ajW635DiJXA==",
"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": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.0.tgz",
@@ -2632,6 +2694,15 @@
"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": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
@@ -3042,6 +3113,15 @@
"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": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
@@ -3631,6 +3711,15 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -3647,7 +3736,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"engines": {
"node": ">=8"
}
@@ -3682,6 +3770,15 @@
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
"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": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
@@ -3764,6 +3861,23 @@
"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": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3981,6 +4095,21 @@
"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": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -4195,6 +4324,12 @@
"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": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -4275,6 +4410,20 @@
"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": {
"version": "4.0.10",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz",
@@ -4344,7 +4493,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -4549,6 +4697,13 @@
"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": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
@@ -4734,6 +4889,12 @@
"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": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz",
@@ -4753,18 +4914,158 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"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": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"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": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -14,6 +14,7 @@
"bootstrap": "^5.3.3",
"bootstrap-icons": "^1.11.3",
"formik": "^2.4.5",
"qrcode": "^1.5.4",
"react": "^18.2.0",
"react-bootstrap": "^2.10.2",
"react-dom": "^18.2.0",
@@ -24,6 +25,7 @@
"yup": "^1.4.0"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"@types/react": "^18.2.64",
"@types/react-dom": "^18.2.21",
"@vitejs/plugin-react": "^4.2.1",
+203 -5
View File
@@ -20,6 +20,9 @@ importers:
formik:
specifier: ^2.4.5
version: 2.4.9(@types/react@18.3.31)(react@18.3.1)
qrcode:
specifier: ^1.5.4
version: 1.5.4
react:
specifier: ^18.2.0
version: 18.3.1
@@ -45,6 +48,9 @@ importers:
specifier: ^1.4.0
version: 1.7.1
devDependencies:
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/react':
specifier: ^18.2.64
version: 18.3.31
@@ -53,7 +59,7 @@ importers:
version: 18.3.7(@types/react@18.3.31)
'@vitejs/plugin-react':
specifier: ^4.2.1
version: 4.7.0(vite@5.4.21)
version: 4.7.0(vite@5.4.21(@types/node@26.1.1))
eslint:
specifier: ^8.57.0
version: 8.57.1
@@ -68,7 +74,7 @@ importers:
version: 0.4.26(eslint@8.57.1)
vite:
specifier: ^5.1.6
version: 5.4.21
version: 5.4.21(@types/node@26.1.1)
packages:
@@ -572,9 +578,15 @@ packages:
'@types/lodash@4.17.24':
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
'@types/node@26.1.1':
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/react-dom@18.3.7':
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
peerDependencies:
@@ -707,6 +719,10 @@ packages:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
engines: {node: '>=6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
caniuse-lite@1.0.30001806:
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
@@ -717,6 +733,9 @@ packages:
classnames@2.5.1:
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
clsx@2.1.1:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
@@ -766,6 +785,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
@@ -785,6 +808,9 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@@ -806,6 +832,9 @@ packages:
electron-to-chromium@1.5.393:
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
es-abstract-get@1.0.0:
resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
engines: {node: '>= 0.4'}
@@ -922,6 +951,10 @@ packages:
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
engines: {node: ^10.12.0 || >=12.0.0}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -968,6 +1001,10 @@ packages:
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
engines: {node: '>=6.9.0'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1103,6 +1140,10 @@ packages:
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
engines: {node: '>= 0.4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-generator-function@1.1.2:
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
engines: {node: '>= 0.4'}
@@ -1210,6 +1251,10 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1298,14 +1343,26 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1328,6 +1385,10 @@ packages:
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
possible-typed-array-names@1.1.0:
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
engines: {node: '>= 0.4'}
@@ -1355,6 +1416,11 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -1445,6 +1511,13 @@ packages:
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
engines: {node: '>= 0.4'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve-from@4.0.0:
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
engines: {node: '>=4'}
@@ -1505,6 +1578,9 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -1549,6 +1625,10 @@ packages:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string.prototype.matchall@4.0.12:
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
engines: {node: '>= 0.4'}
@@ -1641,6 +1721,9 @@ packages:
peerDependencies:
react: '>=16.14.0'
undici-types@8.3.0:
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
update-browserslist-db@1.2.3:
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -1705,6 +1788,9 @@ packages:
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
engines: {node: '>= 0.4'}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-typed-array@1.1.22:
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
engines: {node: '>= 0.4'}
@@ -1718,12 +1804,27 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -2160,8 +2261,16 @@ snapshots:
'@types/lodash@4.17.24': {}
'@types/node@26.1.1':
dependencies:
undici-types: 8.3.0
'@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 26.1.1
'@types/react-dom@18.3.7(@types/react@18.3.31)':
dependencies:
'@types/react': 18.3.31
@@ -2183,7 +2292,7 @@ snapshots:
'@ungap/structured-clone@1.3.3': {}
'@vitejs/plugin-react@4.7.0(vite@5.4.21)':
'@vitejs/plugin-react@4.7.0(vite@5.4.21(@types/node@26.1.1))':
dependencies:
'@babel/core': 7.29.7
'@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
@@ -2191,7 +2300,7 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.27
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
vite: 5.4.21
vite: 5.4.21(@types/node@26.1.1)
transitivePeerDependencies:
- supports-color
@@ -2325,6 +2434,8 @@ snapshots:
callsites@3.1.0: {}
camelcase@5.3.1: {}
caniuse-lite@1.0.30001806: {}
chalk@4.1.2:
@@ -2334,6 +2445,12 @@ snapshots:
classnames@2.5.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
clsx@2.1.1: {}
color-convert@2.0.1:
@@ -2380,6 +2497,8 @@ snapshots:
dependencies:
ms: 2.1.3
decamelize@1.2.0: {}
deep-is@0.1.4: {}
deepmerge@2.2.1: {}
@@ -2398,6 +2517,8 @@ snapshots:
dequal@2.0.3: {}
dijkstrajs@1.0.3: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@@ -2423,6 +2544,8 @@ snapshots:
electron-to-chromium@1.5.393: {}
emoji-regex@8.0.0: {}
es-abstract-get@1.0.0:
dependencies:
es-errors: 1.3.0
@@ -2676,6 +2799,11 @@ snapshots:
dependencies:
flat-cache: 3.2.0
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -2732,6 +2860,8 @@ snapshots:
gensync@1.0.0-beta.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -2884,6 +3014,8 @@ snapshots:
dependencies:
call-bound: 1.0.4
is-fullwidth-code-point@3.0.0: {}
is-generator-function@1.1.2:
dependencies:
call-bound: 1.0.4
@@ -2991,6 +3123,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -3087,14 +3223,24 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
p-try@2.2.0: {}
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -3109,6 +3255,8 @@ snapshots:
picocolors@1.1.1: {}
pngjs@5.0.0: {}
possible-typed-array-names@1.1.0: {}
postcss@8.5.20:
@@ -3135,6 +3283,12 @@ snapshots:
punycode@2.3.1: {}
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
queue-microtask@1.2.3: {}
react-aria@3.50.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
@@ -3255,6 +3409,10 @@ snapshots:
gopd: 1.2.0
set-function-name: 2.0.2
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve-from@4.0.0: {}
resolve@2.0.0-next.7:
@@ -3366,6 +3524,8 @@ snapshots:
semver@6.3.1: {}
set-blocking@2.0.0: {}
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
@@ -3429,6 +3589,12 @@ snapshots:
es-errors: 1.3.0
internal-slot: 1.1.0
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string.prototype.matchall@4.0.12:
dependencies:
call-bind: 1.0.9
@@ -3556,6 +3722,8 @@ snapshots:
dependencies:
react: 18.3.1
undici-types@8.3.0: {}
update-browserslist-db@1.2.3(browserslist@4.28.6):
dependencies:
browserslist: 4.28.6
@@ -3572,12 +3740,13 @@ snapshots:
validator@13.15.35: {}
vite@5.4.21:
vite@5.4.21(@types/node@26.1.1):
dependencies:
esbuild: 0.21.5
postcss: 8.5.20
rollup: 4.62.2
optionalDependencies:
'@types/node': 26.1.1
fsevents: 2.3.3
warning@4.0.3:
@@ -3615,6 +3784,8 @@ snapshots:
is-weakmap: 2.0.2
is-weakset: 2.0.4
which-module@2.0.1: {}
which-typed-array@1.1.22:
dependencies:
available-typed-arrays: 1.0.7
@@ -3631,10 +3802,37 @@ snapshots:
word-wrap@1.2.5: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
y18n@4.0.3: {}
yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yocto-queue@0.1.0: {}
yup@1.7.1:
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

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

After

Width:  |  Height:  |  Size: 391 B

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

After

Width:  |  Height:  |  Size: 1.0 KiB

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

After

Width:  |  Height:  |  Size: 1.3 KiB

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

After

Width:  |  Height:  |  Size: 128 B

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

After

Width:  |  Height:  |  Size: 385 B

+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+40
View File
@@ -0,0 +1,40 @@
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}

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