Compare commits
13 Commits
9bc06f1d5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a4fb7b4d8b | |||
| 0420194541 | |||
| 318e0c3eb4 | |||
| 08fe762426 | |||
| d0f5d4c979 | |||
| 8fd3dbe393 | |||
| 621cf212f5 | |||
| ea49e140e9 | |||
| c55736ee86 | |||
| cdb1eb0902 | |||
| e367cc1a16 | |||
| 319057990a | |||
| 85b31e9528 |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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}"
|
||||||
@@ -6,15 +6,30 @@ This project aims to develop a comprehensive queue management system for bank br
|
|||||||
|
|
||||||
### Teller Interface
|
### 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.
|
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
|
### 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.
|
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
|
### 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.
|
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
|
## 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.
|
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
|
## Setup Instructions
|
||||||
1. Clone the repository to your local machine.
|
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)
|
- [Ahmed Ljubuncic](https://github.com/aljubuncic)
|
||||||
- [Vedran Mujic](https://github.com/vmujic1)
|
- [Vedran Mujic](https://github.com/vmujic1)
|
||||||
- [Amar Tahirovic](https://github.com/amarderschrecklicher)
|
- [Amar Tahirovic](https://github.com/amarderschrecklicher)
|
||||||
|
|
||||||
|
## API ENV GUIDE
|
||||||
|
VITE_API_URL: API for teller and admin (also customer fallback in CI)
|
||||||
|
NEXT_PUBLIC_API_URL: API for customer
|
||||||
|
VITE_BRANCH_QR_BASE_URL: public customer base URL encoded into admin branch QR
|
||||||
|
|
||||||
|
Match these in Gitea secrets and in `fe/web/.env` on the deploy host (ports/images only matter at deploy; API URLs matter at image build).
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
[x] create qr and teller account
|
||||||
|
[x] increase size of gold display
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
target/
|
||||||
|
uploads/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -31,3 +31,9 @@ build/
|
|||||||
|
|
||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
### Uploads ###
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
### Docker / env ###
|
||||||
|
.env
|
||||||
|
|||||||
@@ -1,3 +1,27 @@
|
|||||||
FROM openjdk:17-jdk-alpine
|
# Build stage
|
||||||
COPY target/bbqms-0.0.1-SNAPSHOT.jar ./app.jar
|
# Non-alpine tags are multi-arch (amd64 + arm64). Alpine Maven often has no Mac/ARM image.
|
||||||
ENTRYPOINT ["java", "-jar", "/app.jar"]
|
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"]
|
||||||
|
|||||||
@@ -1,9 +1,38 @@
|
|||||||
## Build instructions
|
## Build instructions (local)
|
||||||
|
|
||||||
- Enter your database details in the **src/main/resources/application.yml** file
|
- Enter your database details in the **src/main/resources/application.yml** file (or use env vars)
|
||||||
- Through IntelliJ simply click on the run button
|
- Through IntelliJ simply click on the run button
|
||||||
- Or through console
|
- Or through console
|
||||||
- ./mvnw dependency:resolve
|
- `./mvnw dependency:resolve`
|
||||||
- ./mvnw spring-boot:run
|
- `./mvnw spring-boot:run`
|
||||||
|
|
||||||
##### **NOTE:** requires Java 17 and MySQL 8
|
##### **NOTE:** requires Java 17 and MySQL 8
|
||||||
|
|
||||||
|
## Docker deployment (single server)
|
||||||
|
|
||||||
|
From the `be/` directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env if ports 8080 / 3306 are already in use on the server
|
||||||
|
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Services and default host ports:
|
||||||
|
|
||||||
|
| Service | Container | Host port (configurable) |
|
||||||
|
|----------|-----------|---------------------------|
|
||||||
|
| Backend | qms-backend | `BACKEND_HOST_PORT` → **8080** |
|
||||||
|
| MySQL | qms-mysql | `MYSQL_HOST_PORT` → **3306** |
|
||||||
|
|
||||||
|
API base URL on the server: `http://<server-ip>:8080`
|
||||||
|
|
||||||
|
Useful commands:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose ps
|
||||||
|
docker compose logs -f backend
|
||||||
|
docker compose down # stop containers (keeps DB volume)
|
||||||
|
docker compose down -v # stop and delete DB/uploads volumes
|
||||||
|
```
|
||||||
|
|||||||
@@ -1,19 +1,35 @@
|
|||||||
services:
|
services:
|
||||||
mysql:
|
mysql:
|
||||||
image: mysql:8
|
image: mysql:8
|
||||||
container_name: bbqms-mysql
|
container_name: qms-mysql
|
||||||
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3306:3306"
|
- "3306:3306"
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: password
|
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-password}
|
||||||
MYSQL_DATABASE: bbqms
|
MYSQL_DATABASE: ${MYSQL_DATABASE:-qms}
|
||||||
volumes:
|
volumes:
|
||||||
- bbqms-mysql-data:/var/lib/mysql
|
- qms-mysql-data:/var/lib/mysql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-ppassword"]
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"mysqladmin",
|
||||||
|
"ping",
|
||||||
|
"-h",
|
||||||
|
"localhost",
|
||||||
|
"-p${MYSQL_ROOT_PASSWORD:-password}",
|
||||||
|
]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 10
|
retries: 20
|
||||||
|
start_period: 20s
|
||||||
|
networks:
|
||||||
|
- qms-net
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
bbqms-mysql-data:
|
qms-mysql-data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
qms-net:
|
||||||
|
driver: bridge
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ public class DefaultAdminService implements AdminService {
|
|||||||
private final RoleService roleService;
|
private final RoleService roleService;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
|
|
||||||
public DefaultAdminService(final UserRepository userRepository,
|
public DefaultAdminService(final UserRepository userRepository,
|
||||||
final UserService userService,
|
final UserService userService,
|
||||||
final TwoFactorService twoFactorService,
|
final TwoFactorService twoFactorService,
|
||||||
@@ -50,7 +51,7 @@ public class DefaultAdminService implements AdminService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<User> findUsersByCode(final String tenantCode, final String roleName){
|
public List<User> findUsersByCode(final String tenantCode, final String roleName) {
|
||||||
final Set<RoleName> roleNameSet = Set.of(RoleName.valueOf(roleName));
|
final Set<RoleName> roleNameSet = Set.of(RoleName.valueOf(roleName));
|
||||||
return this.userRepository.findAllByTenant_CodeAndRoles_NameIn(tenantCode, roleNameSet);
|
return this.userRepository.findAllByTenant_CodeAndRoles_NameIn(tenantCode, roleNameSet);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity;
|
|||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -30,7 +31,8 @@ public class AdminController {
|
|||||||
|
|
||||||
@PostMapping("/{code}")
|
@PostMapping("/{code}")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
||||||
public ResponseEntity getUsers(@RequestBody RoleRequest request, @PathVariable(name = "code") final String tenantCode) {
|
public ResponseEntity<List<UserDto>> getUsers(@RequestBody RoleRequest request,
|
||||||
|
@PathVariable(name = "code") final String tenantCode) {
|
||||||
RoleName roleName;
|
RoleName roleName;
|
||||||
try {
|
try {
|
||||||
roleName = RoleName.valueOf(request.roleName);
|
roleName = RoleName.valueOf(request.roleName);
|
||||||
@@ -39,7 +41,7 @@ public class AdminController {
|
|||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
if(this.authService.canOnlyCRUDUser(roleName)){
|
if (this.authService.canOnlyCRUDUser(roleName)) {
|
||||||
logger.warn("Only super admin can read admins");
|
logger.warn("Only super admin can read admins");
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -52,8 +54,7 @@ public class AdminController {
|
|||||||
return ResponseEntity.ok().body(
|
return ResponseEntity.ok().body(
|
||||||
this.adminService.findUsersByCode(tenantCode, request.roleName).stream()
|
this.adminService.findUsersByCode(tenantCode, request.roleName).stream()
|
||||||
.map(UserDto::fromEntity)
|
.map(UserDto::fromEntity)
|
||||||
.collect(Collectors.toList())
|
.collect(Collectors.toList()));
|
||||||
);
|
|
||||||
} catch (EntityNotFoundException e) {
|
} catch (EntityNotFoundException e) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -61,7 +62,8 @@ public class AdminController {
|
|||||||
|
|
||||||
@PostMapping("/{code}/user")
|
@PostMapping("/{code}/user")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
||||||
public ResponseEntity addUser(@RequestBody final AdminRequest request, @PathVariable(name = "code") final String tenantCode) throws AuthException {
|
public ResponseEntity<UserDto> addUser(@RequestBody final AdminRequest request,
|
||||||
|
@PathVariable(name = "code") final String tenantCode) throws AuthException {
|
||||||
RoleName roleName;
|
RoleName roleName;
|
||||||
try {
|
try {
|
||||||
roleName = RoleName.valueOf(request.roleName);
|
roleName = RoleName.valueOf(request.roleName);
|
||||||
@@ -70,7 +72,7 @@ public class AdminController {
|
|||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
if(this.authService.canOnlyCRUDUser(roleName)){
|
if (this.authService.canOnlyCRUDUser(roleName)) {
|
||||||
logger.warn("Only super admin can add admin");
|
logger.warn("Only super admin can add admin");
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -95,7 +97,8 @@ public class AdminController {
|
|||||||
|
|
||||||
@PutMapping("/{code}/user/{userId}")
|
@PutMapping("/{code}/user/{userId}")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
||||||
public ResponseEntity updateAdmin(@RequestBody final UserDto request, @PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
|
public ResponseEntity<SimpleMessageDto> updateAdmin(@RequestBody final UserDto request,
|
||||||
|
@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
|
||||||
if (!this.authService.canChangeTenant(tenantCode)) {
|
if (!this.authService.canChangeTenant(tenantCode)) {
|
||||||
logger.warn("Admin does not belong to the specified tenant");
|
logger.warn("Admin does not belong to the specified tenant");
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
@@ -112,7 +115,8 @@ public class AdminController {
|
|||||||
|
|
||||||
@DeleteMapping("/{code}/user/{userId}")
|
@DeleteMapping("/{code}/user/{userId}")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN', 'ROLE_STAFF_ADMIN')")
|
||||||
public ResponseEntity removeAdmin(@PathVariable(name = "code") final String tenantCode, @PathVariable(name = "userId") final long adminId) {
|
public ResponseEntity<SimpleMessageDto> removeAdmin(@PathVariable(name = "code") final String tenantCode,
|
||||||
|
@PathVariable(name = "userId") final long adminId) {
|
||||||
if (!this.authService.canChangeTenant(tenantCode)) {
|
if (!this.authService.canChangeTenant(tenantCode)) {
|
||||||
logger.warn("Admin does not belong to the specified tenant");
|
logger.warn("Admin does not belong to the specified tenant");
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
@@ -130,6 +134,6 @@ public class AdminController {
|
|||||||
public record AdminRequest(String email, String password, String roleName) {
|
public record AdminRequest(String email, String password, String roleName) {
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RoleRequest(String roleName){
|
public record RoleRequest(String roleName) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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<Resource> 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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -186,6 +187,7 @@ public class BranchController {
|
|||||||
@GetMapping("/{tenantCode}/{branchId}/queue")
|
@GetMapping("/{tenantCode}/{branchId}/queue")
|
||||||
public ResponseEntity getBranchQueue(@PathVariable final String tenantCode,
|
public ResponseEntity getBranchQueue(@PathVariable final String tenantCode,
|
||||||
@PathVariable final String branchId,
|
@PathVariable final String branchId,
|
||||||
|
@RequestParam(defaultValue = "false") final boolean activeOnly,
|
||||||
final QueueStateParams queueStateParams,
|
final QueueStateParams queueStateParams,
|
||||||
final Sort sort) {
|
final Sort sort) {
|
||||||
final Branch branch = this.branchService.findById(Long.parseLong(branchId)).orElseThrow();
|
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);
|
final TicketRepository ticketRepository = this.ticketService.unwrap(TicketRepository.class);
|
||||||
|
|
||||||
Specification<Ticket> filter = TicketSpecs.branchIdEquals(branch.getId());
|
Specification<Ticket> filter = TicketSpecs.branchIdEquals(branch.getId());
|
||||||
|
if (activeOnly) {
|
||||||
|
filter = filter.and(TicketSpecs.deletedEquals(false));
|
||||||
|
}
|
||||||
if (queueStateParams.serviceId() != null && queueStateParams.serviceId().isPresent()) {
|
if (queueStateParams.serviceId() != null && queueStateParams.serviceId().isPresent()) {
|
||||||
filter = filter.and(TicketSpecs.serviceIdEquals(queueStateParams.serviceId().get()));
|
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.DisplayDto;
|
||||||
import ba.unsa.etf.si.bbqms.ws.models.ErrorResponseDto;
|
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.ServiceDto;
|
||||||
import ba.unsa.etf.si.bbqms.ws.models.ServiceResponseDto;
|
|
||||||
import ba.unsa.etf.si.bbqms.ws.models.TicketDto;
|
import ba.unsa.etf.si.bbqms.ws.models.TicketDto;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
@@ -37,7 +36,7 @@ public class TellerStationController {
|
|||||||
|
|
||||||
@GetMapping("/{tenantCode}")
|
@GetMapping("/{tenantCode}")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_SUPER_ADMIN', 'ROLE_BRANCH_ADMIN')")
|
@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)) {
|
if (!this.authService.canChangeTenant(tenantCode)) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -63,13 +62,13 @@ public class TellerStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final Set<Service> serviceSet = this.stationService.getServicesByAssigned(Long.parseLong(stationId),assigned);
|
final Set<Service> serviceSet = this.stationService.getServicesByAssigned(Long.parseLong(stationId),
|
||||||
final Set<ServiceResponseDto> serviceResponseDtoSet = serviceSet.stream()
|
assigned);
|
||||||
.map(ServiceResponseDto::fromEntity)
|
final Set<ServiceDto> serviceDtoSet = serviceSet.stream()
|
||||||
|
.map(ServiceDto::fromEntity)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
return ResponseEntity.ok().body(serviceResponseDtoSet);
|
return ResponseEntity.ok().body(serviceDtoSet);
|
||||||
}
|
} catch (final Exception e) {
|
||||||
catch (final Exception e) {
|
|
||||||
return ResponseEntity.badRequest().body(new ErrorResponseDto(e.getMessage()));
|
return ResponseEntity.badRequest().body(new ErrorResponseDto(e.getMessage()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,8 +77,9 @@ public class TellerStationController {
|
|||||||
public ResponseEntity findAssignableServices(@PathVariable final String stationId,
|
public ResponseEntity findAssignableServices(@PathVariable final String stationId,
|
||||||
@PathVariable final String tenantCode) {
|
@PathVariable final String tenantCode) {
|
||||||
return ResponseEntity.ok().body(
|
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}")
|
@PutMapping("/{tenantCode}/{stationId}/services/{serviceId}")
|
||||||
@@ -92,7 +92,8 @@ public class TellerStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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));
|
return ResponseEntity.ok().body(TellerStationResponseDto.fromEntity(updatedTellerStation));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
@@ -109,7 +110,8 @@ public class TellerStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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));
|
return ResponseEntity.ok().body(TellerStationResponseDto.fromEntity(updatedTellerStation));
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.badRequest().body(new ErrorResponseDto(e.getMessage()));
|
return ResponseEntity.badRequest().body(new ErrorResponseDto(e.getMessage()));
|
||||||
@@ -126,9 +128,10 @@ public class TellerStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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));
|
return ResponseEntity.ok().body(TellerStationResponseDto.fromEntity(updatedTellerStation));
|
||||||
} catch(final Exception exception) {
|
} catch (final Exception exception) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,9 +146,10 @@ public class TellerStationController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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));
|
return ResponseEntity.ok().body(TellerStationResponseDto.fromEntity(updatedTellerStation));
|
||||||
} catch(final Exception exception) {
|
} catch (final Exception exception) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,12 +157,11 @@ public class TellerStationController {
|
|||||||
@GetMapping("/{tenantCode}/{branchId}")
|
@GetMapping("/{tenantCode}/{branchId}")
|
||||||
public ResponseEntity getBranchStations(@PathVariable final String tenantCode,
|
public ResponseEntity getBranchStations(@PathVariable final String tenantCode,
|
||||||
@PathVariable final String branchId) {
|
@PathVariable final String branchId) {
|
||||||
try{
|
try {
|
||||||
return ResponseEntity.ok().body(
|
return ResponseEntity.ok().body(
|
||||||
this.stationService.getAllByBranch(Long.parseLong(branchId)).stream()
|
this.stationService.getAllByBranch(Long.parseLong(branchId)).stream()
|
||||||
.map(TellerStationResponseDto::fromEntity)
|
.map(TellerStationResponseDto::fromEntity)
|
||||||
.toList()
|
.toList());
|
||||||
);
|
|
||||||
} catch (final Exception exception) {
|
} catch (final Exception exception) {
|
||||||
return ResponseEntity.badRequest().build();
|
return ResponseEntity.badRequest().build();
|
||||||
}
|
}
|
||||||
@@ -168,7 +171,8 @@ public class TellerStationController {
|
|||||||
public ResponseEntity getTicketsForTellerStation(@PathVariable final long stationId) {
|
public ResponseEntity getTicketsForTellerStation(@PathVariable final long stationId) {
|
||||||
try {
|
try {
|
||||||
final List<TicketDto> ticketDtos = this.ticketService.findWithStation(stationId).stream()
|
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)
|
.map(TicketDto::fromEntity)
|
||||||
.toList();
|
.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) {
|
public static TellerStationResponseDto fromEntity(final TellerStation tellerStation) {
|
||||||
final Set<Service> serviceSet = tellerStation.getServices();
|
final Set<Service> serviceSet = tellerStation.getServices();
|
||||||
return new TellerStationResponseDto(
|
return new TellerStationResponseDto(
|
||||||
tellerStation.getId(),
|
tellerStation.getId(),
|
||||||
tellerStation.getName(),
|
tellerStation.getName(),
|
||||||
tellerStation.getDisplay() != null ? DisplayDto.fromEntity(tellerStation.getDisplay()) : null,
|
tellerStation.getDisplay() != null ? DisplayDto.fromEntity(tellerStation.getDisplay()) : null,
|
||||||
serviceSet.stream().map(ServiceResponseDto::fromEntity).collect(Collectors.toSet())
|
serviceSet.stream().map(ServiceDto::fromEntity).collect(Collectors.toSet()));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ public class TenantController {
|
|||||||
|
|
||||||
@PutMapping("/{code}")
|
@PutMapping("/{code}")
|
||||||
@PreAuthorize("isAuthenticated()")
|
@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)) {
|
if (!this.authService.canChangeTenant(code)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,8 @@ public class TenantController {
|
|||||||
|
|
||||||
@PostMapping("/{code}/services")
|
@PostMapping("/{code}/services")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_BRANCH_ADMIN', 'ROLE_SUPER_ADMIN')")
|
@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)) {
|
if (!this.authService.canChangeTenant(code)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
@@ -92,7 +94,8 @@ public class TenantController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{code}/services/group/{groupId}")
|
@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> possibleServices = this.tenantService.getAllServicesByTenant(code);
|
||||||
final List<Service> assignedServices = this.groupService.get(Long.parseLong(groupId))
|
final List<Service> assignedServices = this.groupService.get(Long.parseLong(groupId))
|
||||||
.getServices().stream().toList();
|
.getServices().stream().toList();
|
||||||
@@ -120,7 +123,8 @@ public class TenantController {
|
|||||||
|
|
||||||
@DeleteMapping("/{code}/services/{id}")
|
@DeleteMapping("/{code}/services/{id}")
|
||||||
@PreAuthorize("hasAnyRole('ROLE_BRANCH_ADMIN', 'ROLE_SUPER_ADMIN')")
|
@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)) {
|
if (!this.authService.canChangeTenant(code)) {
|
||||||
return ResponseEntity.notFound().build();
|
return ResponseEntity.notFound().build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package ba.unsa.etf.si.bbqms.ws.models;
|
||||||
|
|
||||||
|
import ba.unsa.etf.si.bbqms.domain.Advertisement;
|
||||||
|
import ba.unsa.etf.si.bbqms.domain.AdvertisementMediaType;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
public record AdvertisementDto(
|
||||||
|
long id,
|
||||||
|
String title,
|
||||||
|
AdvertisementMediaType mediaType,
|
||||||
|
String fileName,
|
||||||
|
String contentType,
|
||||||
|
String mediaUrl,
|
||||||
|
int sortOrder,
|
||||||
|
boolean active,
|
||||||
|
int durationSeconds,
|
||||||
|
Instant createdAt
|
||||||
|
) {
|
||||||
|
public static AdvertisementDto fromEntity(final Advertisement advertisement) {
|
||||||
|
return new AdvertisementDto(
|
||||||
|
advertisement.getId(),
|
||||||
|
advertisement.getTitle(),
|
||||||
|
advertisement.getMediaType(),
|
||||||
|
advertisement.getFileName(),
|
||||||
|
advertisement.getContentType(),
|
||||||
|
"/api/v1/ads/media/" + advertisement.getId(),
|
||||||
|
advertisement.getSortOrder(),
|
||||||
|
advertisement.isActive(),
|
||||||
|
advertisement.getDurationSeconds(),
|
||||||
|
advertisement.getCreatedAt()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +1,43 @@
|
|||||||
|
server:
|
||||||
|
port: ${SERVER_PORT:8080}
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: bbqms
|
name: qms
|
||||||
jpa:
|
jpa:
|
||||||
database: mysql
|
database: mysql
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: none
|
ddl-auto: none
|
||||||
datasource:
|
datasource:
|
||||||
url: jdbc:mysql://localhost:3306/bbqms
|
url: ${SPRING_DATASOURCE_URL:jdbc:mysql://localhost:3306/qms?allowPublicKeyRetrieval=true&useSSL=false}
|
||||||
username: root
|
username: ${SPRING_DATASOURCE_USERNAME:root}
|
||||||
password: password
|
password: ${SPRING_DATASOURCE_PASSWORD:password}
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 100MB
|
||||||
|
max-request-size: 100MB
|
||||||
security:
|
security:
|
||||||
oauth2:
|
oauth2:
|
||||||
client:
|
client:
|
||||||
registration:
|
registration:
|
||||||
google:
|
google:
|
||||||
client-id: dummy-google-client-id
|
client-id: ${GOOGLE_CLIENT_ID:dummy-google-client-id}
|
||||||
flyway:
|
flyway:
|
||||||
schemas: bbqms
|
schemas: qms
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
header-title: Authorization
|
header-title: Authorization
|
||||||
token-prefix: Bearer
|
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
|
authorities-key: USER_AUTHORITIES
|
||||||
token-validity-time: PT30M
|
token-validity-time: PT30M
|
||||||
tfa:
|
tfa:
|
||||||
label: BBQMS
|
label: QMS
|
||||||
issuer: BBQMS
|
issuer: QMS
|
||||||
tenancy:
|
tenancy:
|
||||||
default-code: DFLT
|
default-code: DFLT
|
||||||
notifications:
|
notifications:
|
||||||
expo-url: https://exp.host/--/api/v2/push/send
|
expo-url: https://exp.host/--/api/v2/push/send
|
||||||
mock: 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;
|
||||||
@@ -10,9 +10,12 @@
|
|||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.29.7",
|
||||||
"@expo/metro-runtime": "~3.1.3",
|
"@expo/metro-runtime": "~3.1.3",
|
||||||
|
"@expo/vector-icons": "^14.0.4",
|
||||||
"@react-native-async-storage/async-storage": "^1.23.1",
|
"@react-native-async-storage/async-storage": "^1.23.1",
|
||||||
"@react-native-community/masked-view": "^0.1.11",
|
"@react-native-community/masked-view": "^0.1.11",
|
||||||
|
"@react-native/assets-registry": "0.73.1",
|
||||||
"@react-navigation/bottom-tabs": "^6.5.20",
|
"@react-navigation/bottom-tabs": "^6.5.20",
|
||||||
"@react-navigation/native": "^6.1.17",
|
"@react-navigation/native": "^6.1.17",
|
||||||
"@react-navigation/stack": "^6.3.29",
|
"@react-navigation/stack": "^6.3.29",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.env
|
||||||
|
!.env.example
|
||||||
|
!.env.production
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
.DS_Store
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=http://localhost:8080
|
||||||
|
VITE_BRANCH_QR_BASE_URL=http://localhost:3000
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=https://qms-api.erahn.com.my
|
||||||
|
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_API_URL=https://qms-api.erahn.com.my
|
||||||
|
VITE_BRANCH_QR_BASE_URL=https://qms-customer.erahn.com.my
|
||||||
@@ -1,10 +1,21 @@
|
|||||||
FROM node:21-alpine AS build
|
# Build stage
|
||||||
WORKDIR /admin-app
|
FROM node:20-bookworm AS build
|
||||||
COPY package*.json .
|
WORKDIR /app
|
||||||
RUN npm install
|
|
||||||
|
ARG VITE_API_URL=http://localhost:8080
|
||||||
|
ARG VITE_BRANCH_QR_BASE_URL=http://localhost:3000
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_BRANCH_QR_BASE_URL=$VITE_BRANCH_QR_BASE_URL
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
EXPOSE 5001
|
# Runtime stage — static SPA
|
||||||
CMD ["npm", "run", "preview"]
|
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;"]
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<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">
|
<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" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>BBQMS Admin App</title>
|
<title>MyKOPKB QMS-Admin App</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"formik": "^2.4.5",
|
"formik": "^2.4.5",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-bootstrap": "^2.10.2",
|
"react-bootstrap": "^2.10.2",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"yup": "^1.4.0"
|
"yup": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -1295,11 +1297,31 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz",
|
||||||
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA=="
|
"integrity": "sha512-t7dhREVv6dbNj0q17X12j7yDG4bD/DHYX7o5/DbDxobP0HnGPgpRz2Ej77aL7TZT3DSw13fqUTj8J4mMnqa7WA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "26.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||||
|
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~8.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/prop-types": {
|
"node_modules/@types/prop-types": {
|
||||||
"version": "15.7.11",
|
"version": "15.7.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.11.tgz",
|
||||||
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
|
"integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.2.66",
|
"version": "18.2.66",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.66.tgz",
|
||||||
@@ -1411,7 +1433,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -1695,6 +1716,15 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/camelcase": {
|
||||||
|
"version": "5.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/caniuse-lite": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001597",
|
"version": "1.0.30001597",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001597.tgz",
|
||||||
@@ -1734,6 +1764,17 @@
|
|||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
||||||
},
|
},
|
||||||
|
"node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "1.9.3",
|
"version": "1.9.3",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
|
||||||
@@ -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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1919,6 +1969,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
|
||||||
@@ -1954,6 +2010,12 @@
|
|||||||
"integrity": "sha512-iWgEEvREL4GTXXHKohhh33+6Y8XkPI5eHihDmm8zUk5Zo7HICEW+wI/j5kJ2tbuNUCXJ/sNXa03ajW635DiJXA==",
|
"integrity": "sha512-iWgEEvREL4GTXXHKohhh33+6Y8XkPI5eHihDmm8zUk5Zo7HICEW+wI/j5kJ2tbuNUCXJ/sNXa03ajW635DiJXA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/emoji-regex": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.23.0",
|
"version": "1.23.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.0.tgz",
|
||||||
@@ -2632,6 +2694,15 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/get-caller-file": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz",
|
||||||
@@ -3042,6 +3113,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-fullwidth-code-point": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-generator-function": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz",
|
||||||
@@ -3631,6 +3711,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-try": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -3647,7 +3736,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -3682,6 +3770,15 @@
|
|||||||
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
|
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz",
|
||||||
@@ -3764,6 +3861,23 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -3981,6 +4095,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-directory": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.5",
|
"version": "2.0.0-next.5",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
|
||||||
@@ -4195,6 +4324,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -4275,6 +4410,20 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/string-width": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"emoji-regex": "^8.0.0",
|
||||||
|
"is-fullwidth-code-point": "^3.0.0",
|
||||||
|
"strip-ansi": "^6.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string.prototype.matchall": {
|
"node_modules/string.prototype.matchall": {
|
||||||
"version": "4.0.10",
|
"version": "4.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz",
|
||||||
@@ -4344,7 +4493,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
},
|
},
|
||||||
@@ -4549,6 +4697,13 @@
|
|||||||
"react": ">=15.0.0"
|
"react": ">=15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "8.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||||
|
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.0.13",
|
"version": "1.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
||||||
@@ -4734,6 +4889,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/which-typed-array": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.15",
|
"version": "1.1.15",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz",
|
||||||
@@ -4753,18 +4914,158 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wrap-ansi": {
|
||||||
|
"version": "6.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ansi-styles": "^4.0.0",
|
||||||
|
"string-width": "^4.1.0",
|
||||||
|
"strip-ansi": "^6.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/ansi-styles": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/color-convert": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "~1.1.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=7.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/wrap-ansi/node_modules/color-name": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/wrappy": {
|
"node_modules/wrappy": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/yargs/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"bootstrap": "^5.3.3",
|
"bootstrap": "^5.3.3",
|
||||||
"bootstrap-icons": "^1.11.3",
|
"bootstrap-icons": "^1.11.3",
|
||||||
"formik": "^2.4.5",
|
"formik": "^2.4.5",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-bootstrap": "^2.10.2",
|
"react-bootstrap": "^2.10.2",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"yup": "^1.4.0"
|
"yup": "^1.4.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ importers:
|
|||||||
formik:
|
formik:
|
||||||
specifier: ^2.4.5
|
specifier: ^2.4.5
|
||||||
version: 2.4.9(@types/react@18.3.31)(react@18.3.1)
|
version: 2.4.9(@types/react@18.3.31)(react@18.3.1)
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
react:
|
react:
|
||||||
specifier: ^18.2.0
|
specifier: ^18.2.0
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
@@ -45,6 +48,9 @@ importers:
|
|||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.7.1
|
version: 1.7.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
'@types/react':
|
'@types/react':
|
||||||
specifier: ^18.2.64
|
specifier: ^18.2.64
|
||||||
version: 18.3.31
|
version: 18.3.31
|
||||||
@@ -53,7 +59,7 @@ importers:
|
|||||||
version: 18.3.7(@types/react@18.3.31)
|
version: 18.3.7(@types/react@18.3.31)
|
||||||
'@vitejs/plugin-react':
|
'@vitejs/plugin-react':
|
||||||
specifier: ^4.2.1
|
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:
|
eslint:
|
||||||
specifier: ^8.57.0
|
specifier: ^8.57.0
|
||||||
version: 8.57.1
|
version: 8.57.1
|
||||||
@@ -68,7 +74,7 @@ importers:
|
|||||||
version: 0.4.26(eslint@8.57.1)
|
version: 0.4.26(eslint@8.57.1)
|
||||||
vite:
|
vite:
|
||||||
specifier: ^5.1.6
|
specifier: ^5.1.6
|
||||||
version: 5.4.21
|
version: 5.4.21(@types/node@26.1.1)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
@@ -572,9 +578,15 @@ packages:
|
|||||||
'@types/lodash@4.17.24':
|
'@types/lodash@4.17.24':
|
||||||
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
|
resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==}
|
||||||
|
|
||||||
|
'@types/node@26.1.1':
|
||||||
|
resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==}
|
||||||
|
|
||||||
'@types/prop-types@15.7.15':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/react-dom@18.3.7':
|
'@types/react-dom@18.3.7':
|
||||||
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -707,6 +719,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001806:
|
caniuse-lite@1.0.30001806:
|
||||||
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
||||||
|
|
||||||
@@ -717,6 +733,9 @@ packages:
|
|||||||
classnames@2.5.1:
|
classnames@2.5.1:
|
||||||
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
clsx@2.1.1:
|
clsx@2.1.1:
|
||||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -766,6 +785,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
deep-is@0.1.4:
|
deep-is@0.1.4:
|
||||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||||
|
|
||||||
@@ -785,6 +808,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@@ -806,6 +832,9 @@ packages:
|
|||||||
electron-to-chromium@1.5.393:
|
electron-to-chromium@1.5.393:
|
||||||
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
|
resolution: {integrity: sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==}
|
||||||
|
|
||||||
|
emoji-regex@8.0.0:
|
||||||
|
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
|
||||||
|
|
||||||
es-abstract-get@1.0.0:
|
es-abstract-get@1.0.0:
|
||||||
resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
|
resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -922,6 +951,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==}
|
||||||
engines: {node: ^10.12.0 || >=12.0.0}
|
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:
|
find-up@5.0.0:
|
||||||
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -968,6 +1001,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||||
engines: {node: '>=6.9.0'}
|
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:
|
get-intrinsic@1.3.0:
|
||||||
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1103,6 +1140,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
|
resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
is-generator-function@1.1.2:
|
||||||
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
|
resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1210,6 +1251,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1298,14 +1343,26 @@ packages:
|
|||||||
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
p-locate@5.0.0:
|
p-locate@5.0.0:
|
||||||
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
p-try@2.2.0:
|
||||||
|
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1328,6 +1385,10 @@ packages:
|
|||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
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:
|
possible-typed-array-names@1.1.0:
|
||||||
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1355,6 +1416,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
queue-microtask@1.2.3:
|
queue-microtask@1.2.3:
|
||||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||||
|
|
||||||
@@ -1445,6 +1511,13 @@ packages:
|
|||||||
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
resolve-from@4.0.0:
|
||||||
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@@ -1505,6 +1578,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1549,6 +1625,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
|
||||||
engines: {node: '>= 0.4'}
|
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:
|
string.prototype.matchall@4.0.12:
|
||||||
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
|
resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1641,6 +1721,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
react: '>=16.14.0'
|
react: '>=16.14.0'
|
||||||
|
|
||||||
|
undici-types@8.3.0:
|
||||||
|
resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3:
|
update-browserslist-db@1.2.3:
|
||||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1705,6 +1788,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which-typed-array@1.1.22:
|
which-typed-array@1.1.22:
|
||||||
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
|
resolution: {integrity: sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -1718,12 +1804,27 @@ packages:
|
|||||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
wrappy@1.0.2:
|
wrappy@1.0.2:
|
||||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
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:
|
yocto-queue@0.1.0:
|
||||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -2160,8 +2261,16 @@ snapshots:
|
|||||||
|
|
||||||
'@types/lodash@4.17.24': {}
|
'@types/lodash@4.17.24': {}
|
||||||
|
|
||||||
|
'@types/node@26.1.1':
|
||||||
|
dependencies:
|
||||||
|
undici-types: 8.3.0
|
||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@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)':
|
'@types/react-dom@18.3.7(@types/react@18.3.31)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 18.3.31
|
'@types/react': 18.3.31
|
||||||
@@ -2183,7 +2292,7 @@ snapshots:
|
|||||||
|
|
||||||
'@ungap/structured-clone@1.3.3': {}
|
'@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:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/plugin-transform-react-jsx-self': 7.29.7(@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
|
'@rolldown/pluginutils': 1.0.0-beta.27
|
||||||
'@types/babel__core': 7.20.5
|
'@types/babel__core': 7.20.5
|
||||||
react-refresh: 0.17.0
|
react-refresh: 0.17.0
|
||||||
vite: 5.4.21
|
vite: 5.4.21(@types/node@26.1.1)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -2325,6 +2434,8 @@ snapshots:
|
|||||||
|
|
||||||
callsites@3.1.0: {}
|
callsites@3.1.0: {}
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001806: {}
|
caniuse-lite@1.0.30001806: {}
|
||||||
|
|
||||||
chalk@4.1.2:
|
chalk@4.1.2:
|
||||||
@@ -2334,6 +2445,12 @@ snapshots:
|
|||||||
|
|
||||||
classnames@2.5.1: {}
|
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: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
@@ -2380,6 +2497,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
deep-is@0.1.4: {}
|
deep-is@0.1.4: {}
|
||||||
|
|
||||||
deepmerge@2.2.1: {}
|
deepmerge@2.2.1: {}
|
||||||
@@ -2398,6 +2517,8 @@ snapshots:
|
|||||||
|
|
||||||
dequal@2.0.3: {}
|
dequal@2.0.3: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
doctrine@2.1.0:
|
doctrine@2.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
@@ -2423,6 +2544,8 @@ snapshots:
|
|||||||
|
|
||||||
electron-to-chromium@1.5.393: {}
|
electron-to-chromium@1.5.393: {}
|
||||||
|
|
||||||
|
emoji-regex@8.0.0: {}
|
||||||
|
|
||||||
es-abstract-get@1.0.0:
|
es-abstract-get@1.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -2676,6 +2799,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 3.2.0
|
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:
|
find-up@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
locate-path: 6.0.0
|
locate-path: 6.0.0
|
||||||
@@ -2732,6 +2860,8 @@ snapshots:
|
|||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
||||||
|
get-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind-apply-helpers: 1.0.2
|
call-bind-apply-helpers: 1.0.2
|
||||||
@@ -2884,6 +3014,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
|
|
||||||
|
is-fullwidth-code-point@3.0.0: {}
|
||||||
|
|
||||||
is-generator-function@1.1.2:
|
is-generator-function@1.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bound: 1.0.4
|
call-bound: 1.0.4
|
||||||
@@ -2991,6 +3123,10 @@ snapshots:
|
|||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-locate: 4.1.0
|
||||||
|
|
||||||
locate-path@6.0.0:
|
locate-path@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-locate: 5.0.0
|
p-locate: 5.0.0
|
||||||
@@ -3087,14 +3223,24 @@ snapshots:
|
|||||||
object-keys: 1.1.1
|
object-keys: 1.1.1
|
||||||
safe-push-apply: 1.0.0
|
safe-push-apply: 1.0.0
|
||||||
|
|
||||||
|
p-limit@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
p-try: 2.2.0
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
|
||||||
|
p-locate@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
p-limit: 2.3.0
|
||||||
|
|
||||||
p-locate@5.0.0:
|
p-locate@5.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
p-limit: 3.1.0
|
p-limit: 3.1.0
|
||||||
|
|
||||||
|
p-try@2.2.0: {}
|
||||||
|
|
||||||
parent-module@1.0.1:
|
parent-module@1.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
callsites: 3.1.0
|
callsites: 3.1.0
|
||||||
@@ -3109,6 +3255,8 @@ snapshots:
|
|||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
possible-typed-array-names@1.1.0: {}
|
possible-typed-array-names@1.1.0: {}
|
||||||
|
|
||||||
postcss@8.5.20:
|
postcss@8.5.20:
|
||||||
@@ -3135,6 +3283,12 @@ snapshots:
|
|||||||
|
|
||||||
punycode@2.3.1: {}
|
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: {}
|
queue-microtask@1.2.3: {}
|
||||||
|
|
||||||
react-aria@3.50.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
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
|
gopd: 1.2.0
|
||||||
set-function-name: 2.0.2
|
set-function-name: 2.0.2
|
||||||
|
|
||||||
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve-from@4.0.0: {}
|
resolve-from@4.0.0: {}
|
||||||
|
|
||||||
resolve@2.0.0-next.7:
|
resolve@2.0.0-next.7:
|
||||||
@@ -3366,6 +3524,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
set-function-length@1.2.2:
|
set-function-length@1.2.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
define-data-property: 1.1.4
|
define-data-property: 1.1.4
|
||||||
@@ -3429,6 +3589,12 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
internal-slot: 1.1.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:
|
string.prototype.matchall@4.0.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
call-bind: 1.0.9
|
call-bind: 1.0.9
|
||||||
@@ -3556,6 +3722,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 18.3.1
|
react: 18.3.1
|
||||||
|
|
||||||
|
undici-types@8.3.0: {}
|
||||||
|
|
||||||
update-browserslist-db@1.2.3(browserslist@4.28.6):
|
update-browserslist-db@1.2.3(browserslist@4.28.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.6
|
browserslist: 4.28.6
|
||||||
@@ -3572,12 +3740,13 @@ snapshots:
|
|||||||
|
|
||||||
validator@13.15.35: {}
|
validator@13.15.35: {}
|
||||||
|
|
||||||
vite@5.4.21:
|
vite@5.4.21(@types/node@26.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
postcss: 8.5.20
|
postcss: 8.5.20
|
||||||
rollup: 4.62.2
|
rollup: 4.62.2
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/node': 26.1.1
|
||||||
fsevents: 2.3.3
|
fsevents: 2.3.3
|
||||||
|
|
||||||
warning@4.0.3:
|
warning@4.0.3:
|
||||||
@@ -3615,6 +3784,8 @@ snapshots:
|
|||||||
is-weakmap: 2.0.2
|
is-weakmap: 2.0.2
|
||||||
is-weakset: 2.0.4
|
is-weakset: 2.0.4
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which-typed-array@1.1.22:
|
which-typed-array@1.1.22:
|
||||||
dependencies:
|
dependencies:
|
||||||
available-typed-arrays: 1.0.7
|
available-typed-arrays: 1.0.7
|
||||||
@@ -3631,10 +3802,37 @@ snapshots:
|
|||||||
|
|
||||||
word-wrap@1.2.5: {}
|
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: {}
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
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: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
yup@1.7.1:
|
yup@1.7.1:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -19,27 +19,29 @@ import ManageBranches from './pages/ManageBranchesScreen/ManageBranchesScreen';
|
|||||||
import ManageGroups from './pages/ManageGroupsScreen/ManageGroupsScreen';
|
import ManageGroups from './pages/ManageGroupsScreen/ManageGroupsScreen';
|
||||||
import ManageStations from './pages/ManageStationScreen/ManageStationScreen';
|
import ManageStations from './pages/ManageStationScreen/ManageStationScreen';
|
||||||
import ManageDisplays from './pages/ManageDisplays/ManageDisplays';
|
import ManageDisplays from './pages/ManageDisplays/ManageDisplays';
|
||||||
|
import ManageAdsScreen from './pages/ManageAdsScreen/ManageAdsScreen';
|
||||||
import ManageUsers from './pages/UserManagingScreen/UserManagingScreen';
|
import ManageUsers from './pages/UserManagingScreen/UserManagingScreen';
|
||||||
import ViewQueues from './pages/ViewBranchQueues/ViewBranchQueues';
|
import ViewQueues from './pages/ViewBranchQueues/ViewBranchQueues';
|
||||||
import { ROLES } from './constants.js';
|
import { ROLES } from './constants.js';
|
||||||
|
import { clearSession, getToken, getUserData } from './utils/session.js';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [user, setUser] = useState();
|
const [user, setUser] = useState();
|
||||||
/*
|
/*
|
||||||
Kada se logiramo, ako vec postoji token u localStorage, provjerimo da li je validan (nije istekao)
|
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 storage od starih podataka
|
Ako je validan, ulogujemo usera, ako nije ocistimo session
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const token = localStorage.getItem('token');
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
const url = `${ SERVER_URL }/api/v1/auth`;
|
const url = `${SERVER_URL}/api/v1/auth`;
|
||||||
fetchData(url, 'GET')
|
fetchData(url, 'GET')
|
||||||
.then(({ data, success }) => {
|
.then(({ success }) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
setUser(JSON.parse(localStorage.getItem('userData')));
|
setUser(getUserData());
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem('token');
|
clearSession();
|
||||||
localStorage.removeItem('userData');
|
setUser(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,152 +49,162 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<UserContext.Provider value={ { user, setUser } }>
|
<UserContext.Provider value={{ user, setUser }}>
|
||||||
<Header />
|
<Header />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route exact path="/:tenantCode/manage/displays" element={
|
<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 />
|
<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={
|
<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 />
|
<ManageStations />
|
||||||
</AuthGuard> } />
|
</AuthGuard>} />
|
||||||
<Route exact path="/:tenantCode/manage/groups" element={
|
<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 />
|
<ManageGroups />
|
||||||
</AuthGuard> } />
|
</AuthGuard>} />
|
||||||
|
|
||||||
<Route exact path="/:tenantCode/manage/branches" element={
|
<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 />
|
<ManageBranches />
|
||||||
</AuthGuard> } />
|
</AuthGuard>} />
|
||||||
|
|
||||||
<Route exact path="/:tenantCode/companydetails"
|
<Route exact path="/:tenantCode/companydetails"
|
||||||
element={
|
element={
|
||||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||||
<CompanyInfoUpdate />
|
<CompanyInfoUpdate />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route exact path="/:tenantCode/manage/services" element={
|
<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 />
|
<ManageServices />
|
||||||
</AuthGuard> }
|
</AuthGuard>}
|
||||||
/>
|
/>
|
||||||
<Route exact path="/:tenantCode/manage/users" element={
|
<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 />
|
<ManageUsers />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
} />
|
} />
|
||||||
<Route exact path="/login" element={ <LoginScreen /> } />
|
<Route exact path="/login" element={<LoginScreen />} />
|
||||||
<Route exact path="/:tenantCode/manage/admins" element={
|
<Route exact path="/:tenantCode/manage/admins" element={
|
||||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN] }>
|
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN]}>
|
||||||
<ManageAdmins />
|
<ManageAdmins />
|
||||||
</AuthGuard> } />
|
</AuthGuard>} />
|
||||||
<Route exact path="/profile" element={
|
<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 />
|
<AdminProfile />
|
||||||
</AuthGuard> } />
|
</AuthGuard>} />
|
||||||
<Route exact path="/" element={ <LoginScreen /> } />
|
<Route exact path="/" element={<LoginScreen />} />
|
||||||
<Route exact path="/loginauth"
|
<Route exact path="/loginauth"
|
||||||
element={
|
element={
|
||||||
<AuthGuard roles={ [ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN] }>
|
<AuthGuard roles={[ROLES.ROLE_SUPER_ADMIN, ROLES.ROLE_BRANCH_ADMIN]}>
|
||||||
<LoginAuth />
|
<LoginAuth />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route exact path="/:tenantCode/queues" element={
|
<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 />
|
<ViewQueues />
|
||||||
</AuthGuard> } />
|
</AuthGuard>} />
|
||||||
|
|
||||||
<Route exact path="/:tenantCode/home" element={
|
<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>
|
<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
|
<HomePageCard
|
||||||
title="Manage displays"
|
title="Manage displays"
|
||||||
backgroundColor="var(--dark-blue)"
|
backgroundColor="var(--dark-blue)"
|
||||||
buttonColor="var(--light-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
|
<HomePageCard
|
||||||
title="Manage groups"
|
title="Manage groups"
|
||||||
backgroundColor="var(--light-blue)"
|
backgroundColor="var(--light-blue)"
|
||||||
buttonColor="var(--dark-blue)"
|
buttonColor="var(--dark-blue)"
|
||||||
url={ `/${ user.tenantCode }/manage/groups` }
|
url={`/${user.tenantCode}/manage/groups`}
|
||||||
/>
|
/>
|
||||||
<HomePageCard
|
<HomePageCard
|
||||||
title="Manage branches"
|
title="Manage branches"
|
||||||
backgroundColor="var(--dark-blue)"
|
backgroundColor="var(--dark-blue)"
|
||||||
buttonColor="var(--light-blue)"
|
buttonColor="var(--light-blue)"
|
||||||
url={ `/${ user.tenantCode }/manage/branches` }
|
url={`/${user.tenantCode}/manage/branches`}
|
||||||
/>
|
/>
|
||||||
<HomePageCard
|
<HomePageCard
|
||||||
title="Manage services"
|
title="Manage services"
|
||||||
backgroundColor="var(--light-blue)"
|
backgroundColor="var(--light-blue)"
|
||||||
buttonColor="var(--dark-blue)"
|
buttonColor="var(--dark-blue)"
|
||||||
url={ `/${ user.tenantCode }/manage/services` }
|
url={`/${user.tenantCode}/manage/services`}
|
||||||
/>
|
/>
|
||||||
<HomePageCard
|
<HomePageCard
|
||||||
title="Manage teller stations"
|
title="Manage teller stations"
|
||||||
backgroundColor="var(--light-blue)"
|
backgroundColor="var(--light-blue)"
|
||||||
buttonColor="var(--dark-blue)"
|
buttonColor="var(--dark-blue)"
|
||||||
url={ `/${ user.tenantCode }/manage/stations` }
|
url={`/${user.tenantCode}/manage/stations`}
|
||||||
/>
|
/>
|
||||||
<HomePageCard
|
<HomePageCard
|
||||||
title="Manage company details"
|
title="Manage company details"
|
||||||
backgroundColor="var(--dark-blue)"
|
backgroundColor="var(--dark-blue)"
|
||||||
buttonColor="var(--light-blue)"
|
buttonColor="var(--light-blue)"
|
||||||
url={ `/${ user.tenantCode }/companydetails` }
|
url={`/${user.tenantCode}/companydetails`}
|
||||||
/>
|
/>
|
||||||
<HomePageCard
|
<HomePageCard
|
||||||
title="View queues"
|
title="View queues"
|
||||||
backgroundColor="var(--light-blue)"
|
backgroundColor="var(--light-blue)"
|
||||||
buttonColor="var(--dark-blue)"
|
buttonColor="var(--dark-blue)"
|
||||||
url={ `/${ user.tenantCode }/queues` }
|
url={`/${user.tenantCode}/queues`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) }
|
)}
|
||||||
</>
|
</>
|
||||||
</CanAccess>
|
</CanAccess>
|
||||||
|
|
||||||
<CanAccess roles={ [ROLES.ROLE_SUPER_ADMIN] }>
|
<CanAccess roles={[ROLES.ROLE_SUPER_ADMIN]}>
|
||||||
<>
|
<>
|
||||||
{ user && (
|
{user && (
|
||||||
<>
|
<>
|
||||||
<HomePageCard title="Manage administrators"
|
<HomePageCard title="Manage administrators"
|
||||||
backgroundColor="var(--dark-blue)"
|
backgroundColor="var(--dark-blue)"
|
||||||
buttonColor="var(--light-blue)"
|
buttonColor="var(--light-blue)"
|
||||||
url={ `/${ user.tenantCode }/manage/admins` }></HomePageCard>
|
url={`/${user.tenantCode}/manage/admins`}></HomePageCard>
|
||||||
<HomePageCard
|
<HomePageCard
|
||||||
title="Manage users"
|
title="Manage Teller"
|
||||||
backgroundColor="var(--light-blue)"
|
backgroundColor="var(--light-blue)"
|
||||||
buttonColor="var(--dark-blue)"
|
buttonColor="var(--dark-blue)"
|
||||||
url={ `/${ user.tenantCode }/manage/users` }
|
url={`/${user.tenantCode}/manage/users`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
) }
|
)}
|
||||||
</>
|
</>
|
||||||
</CanAccess>
|
</CanAccess>
|
||||||
|
|
||||||
<CanAccess roles={ [ROLES.ROLE_BRANCH_ADMIN] }>
|
<CanAccess roles={[ROLES.ROLE_BRANCH_ADMIN]}>
|
||||||
{ user && (
|
{user && (
|
||||||
<HomePageCard
|
<HomePageCard
|
||||||
title="Manage users"
|
title="Manage users"
|
||||||
backgroundColor="var(--dark-blue)"
|
backgroundColor="var(--dark-blue)"
|
||||||
buttonColor="var(--light-blue)"
|
buttonColor="var(--light-blue)"
|
||||||
url={ `/${ user.tenantCode }/manage/users` }
|
url={`/${user.tenantCode}/manage/users`}
|
||||||
/>
|
/>
|
||||||
) }
|
)}
|
||||||
</CanAccess>
|
</CanAccess>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
} />
|
} />
|
||||||
<Route path="*" element={ <NotFound /> } />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</UserContext.Provider>
|
</UserContext.Provider>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { getUserData } from '../../utils/session.js';
|
||||||
|
|
||||||
export default function AuthGuard({ children, roles }) {
|
export default function AuthGuard({ children, roles }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUserData = localStorage.getItem('userData');
|
const user = getUserData();
|
||||||
const user = storedUserData ? JSON.parse(storedUserData) : null;
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -23,7 +23,7 @@ export default function AuthGuard({ children, roles }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{ children }
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,20 +2,19 @@ import { useContext } from 'react';
|
|||||||
import { Button } from 'react-bootstrap';
|
import { Button } from 'react-bootstrap';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { lastPathPart } from '../../utils/StringUtils.js';
|
import { lastPathPart } from '../../utils/StringUtils.js';
|
||||||
import profileImage from '../../../assets/profile-user.png'
|
|
||||||
|
|
||||||
import './Header.css';
|
import './Header.css';
|
||||||
import { UserContext } from '../../context/UserContext.jsx';
|
import { UserContext } from '../../context/UserContext.jsx';
|
||||||
|
import { clearSession } from '../../utils/session.js';
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user, setUser } = useContext(UserContext);
|
const { user, setUser } = useContext(UserContext);
|
||||||
|
|
||||||
function handleLogout() {
|
function handleLogout() {
|
||||||
localStorage.removeItem('userData');
|
clearSession();
|
||||||
localStorage.removeItem('token');
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
navigate('/');
|
navigate('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = window.location.pathname;
|
const path = window.location.pathname;
|
||||||
@@ -35,28 +34,31 @@ export default function Header() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="main-header">
|
<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">
|
<div className="header-logout">
|
||||||
{ !!user && (
|
{!!user && (
|
||||||
<button className="header-logout-btn" onClick={ handleLogout }>
|
<button
|
||||||
|
className="header-logout-btn"
|
||||||
|
onClick={handleLogout}
|
||||||
|
>
|
||||||
Logout
|
Logout
|
||||||
</button>
|
</button>
|
||||||
) }
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="header-profile" onClick={ () => navigate('/profile') }>
|
|
||||||
<img src={ profileImage } className="header-profile-png" alt="Profile image" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{ showBackButton && (
|
|
||||||
<Button variant="secondary"
|
{showBackButton && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
className="mt-2 px-4"
|
className="mt-2 px-4"
|
||||||
onClick={ goHome }>
|
onClick={goHome}
|
||||||
|
>
|
||||||
Back
|
Back
|
||||||
</Button>
|
</Button>
|
||||||
) }
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import { SERVER_URL } from "../../constants.js";
|
|||||||
import { fetchData } from '../../fetching/Fetch.js';
|
import { fetchData } from '../../fetching/Fetch.js';
|
||||||
import { UserContext } from '../../context/UserContext.jsx';
|
import { UserContext } from '../../context/UserContext.jsx';
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { getUserData, setSession } from '../../utils/session.js';
|
||||||
|
|
||||||
function doSubmit(submittedValues) {
|
function doSubmit(submittedValues) {
|
||||||
console.log(`Submitted: ${submittedValues.join("")}`);
|
console.log(`Submitted: ${submittedValues.join("")}`);
|
||||||
@@ -126,10 +127,9 @@ export default function LoginAuth() {
|
|||||||
dispatch({ type: "VERIFY" });
|
dispatch({ type: "VERIFY" });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const storedUserData = localStorage.getItem('userData');
|
const userData = getUserData();
|
||||||
const userData = storedUserData ? JSON.parse(storedUserData) : null;
|
|
||||||
if (!userData) {
|
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`;
|
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
|
||||||
@@ -139,7 +139,11 @@ export default function LoginAuth() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
localStorage.setItem('token', data.token);
|
setSession({
|
||||||
|
token: data.token,
|
||||||
|
userData: data.userData,
|
||||||
|
isTfa: true,
|
||||||
|
});
|
||||||
setUser(data.userData);
|
setUser(data.userData);
|
||||||
navigate(`/${ data.userData.tenantCode }/home`);
|
navigate(`/${ data.userData.tenantCode }/home`);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "./LoginForm.css";
|
|||||||
import LoginAuth from "../LoginAuth/LoginAuth";
|
import LoginAuth from "../LoginAuth/LoginAuth";
|
||||||
import { Route, Routes, useNavigate, Link } from "react-router-dom";
|
import { Route, Routes, useNavigate, Link } from "react-router-dom";
|
||||||
import { SERVER_URL } from "../../constants.js";
|
import { SERVER_URL } from "../../constants.js";
|
||||||
|
import { setSession } from "../../utils/session.js";
|
||||||
|
|
||||||
const LoginForm = () => {
|
const LoginForm = () => {
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
@@ -28,7 +29,11 @@ const LoginForm = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
localStorage.setItem('userData', JSON.stringify(data));
|
setSession({
|
||||||
|
userData: data.userData ?? data,
|
||||||
|
token: data.token,
|
||||||
|
isTfa: !data.token,
|
||||||
|
});
|
||||||
navigate('/');
|
navigate('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +72,12 @@ const LoginForm = () => {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
localStorage.setItem('userData', JSON.stringify(data));
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
setSession({
|
||||||
|
userData: data.userData ?? data,
|
||||||
|
token: data.token,
|
||||||
|
isTfa: !data.token,
|
||||||
|
});
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} else if (response.status === 403) {
|
} else if (response.status === 403) {
|
||||||
|
|||||||
@@ -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 = {
|
export const ROLES = {
|
||||||
ROLE_SUPER_ADMIN : "ROLE_SUPER_ADMIN",
|
ROLE_SUPER_ADMIN: 'ROLE_SUPER_ADMIN',
|
||||||
ROLE_BRANCH_ADMIN : "ROLE_BRANCH_ADMIN"
|
ROLE_BRANCH_ADMIN: 'ROLE_BRANCH_ADMIN',
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/*
|
/*
|
||||||
Koristiti ovu funkciju za fetchanje u buducnosti kad god je to moguce.
|
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) {
|
export async function fetchData(url, method, body) {
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = getToken();
|
||||||
if (token) {
|
if (token) {
|
||||||
headers.append('Authorization', `Bearer ${ token }`);
|
headers.append('Authorization', `Bearer ${ token }`);
|
||||||
}
|
}
|
||||||
@@ -27,7 +29,7 @@ export async function fetchData(url, method, body) {
|
|||||||
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
|
//na svaki ispravan rezultat treba da dobijemo novi token da refreshamo stari
|
||||||
const newToken = res.headers.get('Auth-Token');
|
const newToken = res.headers.get('Auth-Token');
|
||||||
if (newToken) {
|
if (newToken) {
|
||||||
localStorage.setItem('token', newToken);
|
setToken(newToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { SERVER_URL } from '../../constants.js';
|
||||||
import { UserContext } from '../../context/UserContext.jsx';
|
import { UserContext } from '../../context/UserContext.jsx';
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import { getToken } from '../../utils/session.js';
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
@@ -30,22 +31,22 @@ const AdminManageScreen = () => {
|
|||||||
const [adminEmail, setAdminEmail] = useState('');
|
const [adminEmail, setAdminEmail] = useState('');
|
||||||
const [adminPassword, setAdminPassword] = useState('');
|
const [adminPassword, setAdminPassword] = useState('');
|
||||||
const [selectedAdminIndex, setSelectedAdminIndex] = useState(null);
|
const [selectedAdminIndex, setSelectedAdminIndex] = useState(null);
|
||||||
const [token, setToken] = useState('');
|
|
||||||
const [emailError, setEmailError] = useState('');
|
const [emailError, setEmailError] = useState('');
|
||||||
const [passwordError, setPasswordError] = useState('');
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedToken = localStorage.getItem('token');
|
if (getToken()) {
|
||||||
if (storedToken) {
|
fetchAdmins();
|
||||||
setToken(storedToken);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const authHeaders = () => {
|
||||||
if (token) {
|
const token = getToken();
|
||||||
fetchAdmins();
|
return {
|
||||||
}
|
'Content-Type': 'application/json',
|
||||||
}, [token]);
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const fetchAdmins = async () => {
|
const fetchAdmins = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -54,10 +55,7 @@ const AdminManageScreen = () => {
|
|||||||
});
|
});
|
||||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
|
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token
|
|
||||||
},
|
|
||||||
body: requestBody
|
body: requestBody
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -87,10 +85,7 @@ const AdminManageScreen = () => {
|
|||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody)
|
body: JSON.stringify(requestBody)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,10 +117,7 @@ const AdminManageScreen = () => {
|
|||||||
};
|
};
|
||||||
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${admins[selectedAdminIndex].id}`, {
|
const response = await fetch(`${ SERVER_URL }/api/v1/admin/${tenantCode}/user/${admins[selectedAdminIndex].id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updatedAdmin),
|
body: JSON.stringify(updatedAdmin),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -146,10 +138,7 @@ const AdminManageScreen = () => {
|
|||||||
try {
|
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',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const updatedAdmins = admins.filter(admin => admin.id !== userId);
|
const updatedAdmins = admins.filter(admin => admin.id !== userId);
|
||||||
|
|||||||
@@ -1,53 +1,51 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { fetchData } from '../../fetching/Fetch.js';
|
import { fetchData } from '../../fetching/Fetch.js';
|
||||||
import { SERVER_URL } from '../../constants';
|
import { SERVER_URL } from '../../constants';
|
||||||
|
import { getIsTfa, getUserData, setIsTfa } from '../../utils/session.js';
|
||||||
import "./AdminProfile.css"
|
import "./AdminProfile.css"
|
||||||
|
|
||||||
export default function AdminProfile(){
|
export default function AdminProfile() {
|
||||||
const [isChecked, setIsChecked] = useState(false);
|
const [isChecked, setIsChecked] = useState(false);
|
||||||
const [isQRCodeEnabled, setIsQRCodeEnabled] = useState(false);
|
const [isQRCodeEnabled, setIsQRCodeEnabled] = useState(false);
|
||||||
const [qrCodeSrc, setQrCodeSrc] = useState('');
|
const [qrCodeSrc, setQrCodeSrc] = useState('');
|
||||||
const [userData, setUserData] = useState('');
|
const [userData, setUserData] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedUserData = localStorage.getItem('userData');
|
setUserData(getUserData());
|
||||||
setUserData(JSON.parse(storedUserData));
|
|
||||||
|
|
||||||
const storedIsTfa = localStorage.getItem('isTfa');
|
|
||||||
let isTfa = JSON.parse(storedIsTfa);
|
|
||||||
|
|
||||||
|
const isTfa = getIsTfa();
|
||||||
setIsChecked(isTfa);
|
setIsChecked(isTfa);
|
||||||
setIsQRCodeEnabled(isTfa);
|
setIsQRCodeEnabled(isTfa);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSaveChanges = async () =>{
|
const handleSaveChanges = async () => {
|
||||||
const url = `${ SERVER_URL }/api/v1/auth/tfa`;
|
const url = `${SERVER_URL}/api/v1/auth/tfa`;
|
||||||
const { data, success } = await fetchData(url, 'PUT', {
|
const { data, success } = await fetchData(url, 'PUT', {
|
||||||
isTfa: isChecked
|
isTfa: isChecked
|
||||||
});
|
});
|
||||||
localStorage.setItem('isTfa', isChecked);
|
setIsTfa(isChecked);
|
||||||
setIsQRCodeEnabled(isChecked);
|
setIsQRCodeEnabled(isChecked);
|
||||||
if(!isChecked){
|
if (!isChecked) {
|
||||||
setQrCodeSrc('');
|
setQrCodeSrc('');
|
||||||
}
|
}
|
||||||
if(success){
|
if (success) {
|
||||||
let message = 'Success: Your changes have been successfully submitted.';
|
let message = 'Success: Your changes have been successfully submitted.';
|
||||||
if(isChecked){
|
if (isChecked) {
|
||||||
message = message + '\nPlease scan QR code.';
|
message = message + '\nPlease scan QR code.';
|
||||||
}
|
}
|
||||||
alert(message);
|
alert(message);
|
||||||
}else{
|
} else {
|
||||||
alert('An error occurred. Please try again.');
|
alert('An error occurred. Please try again.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCheckBoxChange = () =>{
|
const handleCheckBoxChange = () => {
|
||||||
setIsChecked(!isChecked);
|
setIsChecked(!isChecked);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleGenerateQRCode = () =>{
|
const handleGenerateQRCode = () => {
|
||||||
if(isQRCodeEnabled){
|
if (isQRCodeEnabled) {
|
||||||
const url = `${ SERVER_URL }/api/v1/auth/tfa?email=${userData.email}`;
|
const url = `${SERVER_URL}/api/v1/auth/tfa?email=${userData.email}`;
|
||||||
fetchData(url, 'GET')
|
fetchData(url, 'GET')
|
||||||
.then(({ data, success }) => {
|
.then(({ data, success }) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -61,17 +59,17 @@ export default function AdminProfile(){
|
|||||||
<div id="account-settings">
|
<div id="account-settings">
|
||||||
<h1>Account settings</h1>
|
<h1>Account settings</h1>
|
||||||
<div id="check-box">
|
<div id="check-box">
|
||||||
<form>
|
{/* <form>
|
||||||
<label htmlFor="2fa">Use two-factor authentication:</label>
|
<label htmlFor="2fa">Use two-factor authentication:</label>
|
||||||
<input type="checkbox" id="2fa" name="2fa" checked={isChecked} onChange={handleCheckBoxChange}></input>
|
<input type="checkbox" id="2fa" name="2fa" checked={isChecked} onChange={handleCheckBoxChange}></input>
|
||||||
</form>
|
</form> */}
|
||||||
</div>
|
</div>
|
||||||
<div id="QR-code">
|
{/* <div id="QR-code">
|
||||||
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode}/>
|
<input type="submit" value="Generate QR code" disabled={!isQRCodeEnabled} onClick={handleGenerateQRCode} />
|
||||||
<img src={qrCodeSrc}></img>
|
<img src={qrCodeSrc}></img>
|
||||||
</div>
|
</div> */}
|
||||||
<div>
|
<div>
|
||||||
<input type="submit" value="Save changes" onClick={handleSaveChanges}/>
|
<input type="submit" value="Save changes" onClick={handleSaveChanges} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
|
import React, { useContext, useState } from 'react';
|
||||||
import React, { useState } from 'react';
|
|
||||||
import validator from 'validator';
|
import validator from 'validator';
|
||||||
import './LoginScreen.css';
|
import './LoginScreen.css';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { 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() {
|
export default function LoginScreen() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { setUser } = useContext(UserContext);
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -38,26 +47,42 @@ export default function LoginScreen() {
|
|||||||
password: password
|
password: password
|
||||||
});
|
});
|
||||||
|
|
||||||
if (success) {
|
if (!success || !data) {
|
||||||
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 {
|
|
||||||
setError('Your credentials are incorrect.');
|
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) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
setError('An error occurred. Please try again.');
|
setError('An error occurred. Please try again.');
|
||||||
@@ -73,11 +98,6 @@ export default function LoginScreen() {
|
|||||||
setPassword(event.target.value);
|
setPassword(event.target.value);
|
||||||
setError('');
|
setError('');
|
||||||
};
|
};
|
||||||
/*
|
|
||||||
if (isSubmitted) {
|
|
||||||
//navigate('/loginAuth');
|
|
||||||
navigate('/companydetails');
|
|
||||||
}*/
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div id="login-form">
|
<div id="login-form">
|
||||||
@@ -92,9 +112,7 @@ export default function LoginScreen() {
|
|||||||
value={username}
|
value={username}
|
||||||
onChange={handleUsernameChange}
|
onChange={handleUsernameChange}
|
||||||
/>
|
/>
|
||||||
{error && (error.includes('Username') || error.includes('Invalid')) &&
|
{error && !error.includes('Password') && <p className="error">{error}</p>}
|
||||||
<p className="error">{error}</p>}
|
|
||||||
{error && (error.includes('credentials')) && <p className="error">{error}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="password">Password:</label>
|
<label htmlFor="password">Password:</label>
|
||||||
|
|||||||
@@ -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 { fetchData } from '../../fetching/Fetch.js';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { SERVER_URL } from '../../constants.js';
|
import { SERVER_URL } from '../../constants.js';
|
||||||
|
import {
|
||||||
|
downloadBranchQr,
|
||||||
|
generateBranchQrDataUrl,
|
||||||
|
getBranchTicketUrl,
|
||||||
|
} from '../../utils/branchQr.js';
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
@@ -30,6 +35,9 @@ const ManageBranchesScreen = () => {
|
|||||||
const [newStationName, setNewStationName] = useState('');
|
const [newStationName, setNewStationName] = useState('');
|
||||||
const [deleteConfirmation, setDeleteConfirmation] = useState(false);
|
const [deleteConfirmation, setDeleteConfirmation] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
const [qrModalBranch, setQrModalBranch] = useState(null);
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('');
|
||||||
|
const [qrLoading, setQrLoading] = useState(false);
|
||||||
const { tenantCode } = useParams();
|
const { tenantCode } = useParams();
|
||||||
|
|
||||||
const url = `${SERVER_URL}/api/v1/branches/${tenantCode}`;
|
const url = `${SERVER_URL}/api/v1/branches/${tenantCode}`;
|
||||||
@@ -154,6 +162,42 @@ const ManageBranchesScreen = () => {
|
|||||||
setSelectedBranchIndex(index);
|
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 confirmDeleteBranch = async () => {
|
||||||
const branchId = manageBranches[selectedBranchIndex].id;
|
const branchId = manageBranches[selectedBranchIndex].id;
|
||||||
const urlToDelete = `${url}/${branchId}`;
|
const urlToDelete = `${url}/${branchId}`;
|
||||||
@@ -221,6 +265,7 @@ const ManageBranchesScreen = () => {
|
|||||||
<td>{branch.tellerStations ? branch.tellerStations.map(station => station.name).join(', ') : '-'}</td>
|
<td>{branch.tellerStations ? branch.tellerStations.map(station => station.name).join(', ') : '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<Button variant="info" style={styles.infoButton} onClick={() => handleEditClick(index)}>Edit</Button>{' '}
|
<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>
|
<Button variant="danger" onClick={() => handleDeleteBranch(index)}>Delete</Button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -270,6 +315,46 @@ const ManageBranchesScreen = () => {
|
|||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal>
|
</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 show={deleteConfirmation} onHide={() => setDeleteConfirmation(false)}>
|
||||||
<Modal.Header closeButton style={{ backgroundColor: '#334257', color: 'white' }}>
|
<Modal.Header closeButton style={{ backgroundColor: '#334257', color: 'white' }}>
|
||||||
<Modal.Title>CONFIRMATION</Modal.Title>
|
<Modal.Title>CONFIRMATION</Modal.Title>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Button, Table, Modal, Form } from 'react-bootstrap';
|
import { Button, Table, Modal, Form } from 'react-bootstrap';
|
||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import { SERVER_URL } from '../../constants.js';
|
import { SERVER_URL } from '../../constants.js';
|
||||||
import { UserContext } from '../../context/UserContext.jsx';
|
import { useParams } from "react-router-dom";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { getToken } from '../../utils/session.js';
|
||||||
|
|
||||||
const styles = {
|
const styles = {
|
||||||
primaryButton: {
|
primaryButton: {
|
||||||
@@ -29,34 +29,31 @@ const UserManageScreen = () => {
|
|||||||
const [userEmail, setUserEmail] = useState('');
|
const [userEmail, setUserEmail] = useState('');
|
||||||
const [userPassword, setUserPassword] = useState('');
|
const [userPassword, setUserPassword] = useState('');
|
||||||
const [selectedUserIndex, setSelectedUserIndex] = useState(null);
|
const [selectedUserIndex, setSelectedUserIndex] = useState(null);
|
||||||
const [token, setToken] = useState('');
|
|
||||||
const [emailError, setEmailError] = useState('');
|
const [emailError, setEmailError] = useState('');
|
||||||
const [passwordError, setPasswordError] = useState('');
|
const [passwordError, setPasswordError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedToken = localStorage.getItem('token');
|
if (getToken()) {
|
||||||
if (storedToken) {
|
fetchUsers();
|
||||||
setToken(storedToken);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
const authHeaders = () => {
|
||||||
if (token) {
|
const token = getToken();
|
||||||
fetchUsers();
|
return {
|
||||||
}
|
'Content-Type': 'application/json',
|
||||||
}, [token]);
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const fetchUsers = async () => {
|
const fetchUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const requestBody = JSON.stringify({
|
const requestBody = JSON.stringify({
|
||||||
roleName: 'ROLE_USER'
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token
|
|
||||||
},
|
|
||||||
body: requestBody
|
body: requestBody
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -84,12 +81,9 @@ const UserManageScreen = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody)
|
body: JSON.stringify(requestBody)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -119,12 +113,9 @@ const UserManageScreen = () => {
|
|||||||
const updatedUser = {
|
const updatedUser = {
|
||||||
email: userEmail
|
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',
|
method: 'PUT',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token
|
|
||||||
},
|
|
||||||
body: JSON.stringify(updatedUser),
|
body: JSON.stringify(updatedUser),
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -144,12 +135,9 @@ const UserManageScreen = () => {
|
|||||||
|
|
||||||
const handleDeleteUser = async (userId) => {
|
const handleDeleteUser = async (userId) => {
|
||||||
try {
|
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',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: authHeaders(),
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': token
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const updatedUsers = users.filter(user => user.id !== userId);
|
const updatedUsers = users.filter(user => user.id !== userId);
|
||||||
@@ -171,7 +159,11 @@ const UserManageScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-center">
|
<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>
|
<Button variant="primary" style={styles.primaryButton} className="mb-3" onClick={() => { setShowModal(true); setSelectedUserIndex(null); }}>Add User</Button>
|
||||||
|
|
||||||
<Table striped bordered hover>
|
<Table striped bordered hover>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
const TOKEN_KEY = 'token';
|
||||||
|
const USER_DATA_KEY = 'userData';
|
||||||
|
const IS_TFA_KEY = 'isTfa';
|
||||||
|
|
||||||
|
/** Matches backend jwt.token-validity-time (PT30M). */
|
||||||
|
const SESSION_MAX_AGE_SECONDS = 30 * 60;
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const prefix = `${encodeURIComponent(name)}=`;
|
||||||
|
const parts = document.cookie ? document.cookie.split('; ') : [];
|
||||||
|
|
||||||
|
for (const part of parts) {
|
||||||
|
if (part.startsWith(prefix)) {
|
||||||
|
return decodeURIComponent(part.slice(prefix.length));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCookie(name, value, maxAgeSeconds = SESSION_MAX_AGE_SECONDS) {
|
||||||
|
const secure = window.location.protocol === 'https:' ? '; Secure' : '';
|
||||||
|
document.cookie = [
|
||||||
|
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
||||||
|
`Path=/`,
|
||||||
|
`Max-Age=${maxAgeSeconds}`,
|
||||||
|
`SameSite=Lax`,
|
||||||
|
secure,
|
||||||
|
].join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCookie(name) {
|
||||||
|
document.cookie = `${encodeURIComponent(name)}=; Path=/; Max-Age=0; SameSite=Lax`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateFromLocalStorage(key) {
|
||||||
|
const legacy = localStorage.getItem(key);
|
||||||
|
if (legacy == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(key, legacy);
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getToken() {
|
||||||
|
return getCookie(TOKEN_KEY) ?? migrateFromLocalStorage(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setToken(token) {
|
||||||
|
if (token == null || token === '') {
|
||||||
|
removeCookie(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(TOKEN_KEY, token);
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getUserData() {
|
||||||
|
const raw = getCookie(USER_DATA_KEY) ?? migrateFromLocalStorage(USER_DATA_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
removeCookie(USER_DATA_KEY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setUserData(userData) {
|
||||||
|
if (userData == null) {
|
||||||
|
removeCookie(USER_DATA_KEY);
|
||||||
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCookie(USER_DATA_KEY, JSON.stringify(userData));
|
||||||
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIsTfa() {
|
||||||
|
const raw = getCookie(IS_TFA_KEY) ?? migrateFromLocalStorage(IS_TFA_KEY);
|
||||||
|
if (raw == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return raw === 'true';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setIsTfa(isTfa) {
|
||||||
|
setCookie(IS_TFA_KEY, JSON.stringify(Boolean(isTfa)));
|
||||||
|
localStorage.removeItem(IS_TFA_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSession({ token, userData, isTfa } = {}) {
|
||||||
|
if (token !== undefined) {
|
||||||
|
setToken(token);
|
||||||
|
}
|
||||||
|
if (userData !== undefined) {
|
||||||
|
setUserData(userData);
|
||||||
|
}
|
||||||
|
if (isTfa !== undefined) {
|
||||||
|
setIsTfa(isTfa);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearSession() {
|
||||||
|
removeCookie(TOKEN_KEY);
|
||||||
|
removeCookie(USER_DATA_KEY);
|
||||||
|
removeCookie(IS_TFA_KEY);
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
|
localStorage.removeItem(IS_TFA_KEY);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
.DS_Store
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.*
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<!-- BEGIN:nextjs-agent-rules -->
|
||||||
|
# This is NOT the Next.js you know
|
||||||
|
|
||||||
|
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||||
|
<!-- END:nextjs-agent-rules -->
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@AGENTS.md
|
||||||
@@ -0,0 +1,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"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: #fafafa;
|
||||||
|
--foreground: #171717;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--font-sans: var(--font-geist-sans);
|
||||||
|
--font-mono: var(--font-geist-mono);
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
|
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
const geistSans = Geist({
|
||||||
|
variable: "--font-geist-sans",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
const geistMono = Geist_Mono({
|
||||||
|
variable: "--font-geist-mono",
|
||||||
|
subsets: ["latin"],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "MyKOPKB QMS-Customer App",
|
||||||
|
description: "MyKOPKB QMS-Customer App",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||||
|
>
|
||||||
|
<body className="min-h-full flex flex-col">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||||
|
<CustomerTicketFlow />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
|
||||||
|
import { decodeBranchQrToken } from "@/lib/branchToken";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
token: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BranchQrPage({ params }: PageProps) {
|
||||||
|
const { token } = await params;
|
||||||
|
const payload = decodeBranchQrToken(token);
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||||
|
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
|
||||||
|
<p className="text-zinc-600">
|
||||||
|
This branch QR link is not valid. Scan again or enter a company code.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Enter company code
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||||
|
<CustomerTicketFlow
|
||||||
|
initialTenantCode={payload.tenantCode}
|
||||||
|
initialBranchId={payload.branchId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import CustomerTicketFlow from "@/components/CustomerTicketFlow";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{
|
||||||
|
tenantCode: string;
|
||||||
|
branchId: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BranchTicketPage({ params }: PageProps) {
|
||||||
|
const { tenantCode, branchId: branchIdParam } = await params;
|
||||||
|
const branchId = Number(branchIdParam);
|
||||||
|
|
||||||
|
if (!Number.isFinite(branchId)) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||||
|
<main className="mx-auto flex w-full max-w-xl flex-col gap-4 px-6 py-12">
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-900">Invalid link</h1>
|
||||||
|
<p className="text-zinc-600">
|
||||||
|
This branch QR link is not valid. Scan again or enter a company code.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="text-sm text-zinc-900 underline-offset-2 hover:underline"
|
||||||
|
>
|
||||||
|
Enter company code
|
||||||
|
</Link>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-full flex-1 flex-col bg-zinc-50">
|
||||||
|
<CustomerTicketFlow
|
||||||
|
initialTenantCode={tenantCode}
|
||||||
|
initialBranchId={branchId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,495 @@
|
|||||||
|
"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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Prevents spam of "Dapatkan tiket lain" right after getting a number. */
|
||||||
|
const ANOTHER_TICKET_COOLDOWN_MS = 10_000;
|
||||||
|
|
||||||
|
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 [cooldownLeftMs, setCooldownLeftMs] = useState(0);
|
||||||
|
const bootstrapped = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (step !== "ticket" || !ticket) {
|
||||||
|
setCooldownLeftMs(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const elapsed = Date.now() - new Date(ticket.createdAt).getTime();
|
||||||
|
setCooldownLeftMs(
|
||||||
|
Math.max(0, ANOTHER_TICKET_COOLDOWN_MS - elapsed)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 250);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [step, ticket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
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.";
|
||||||
|
|
||||||
|
const canGetAnotherTicket = cooldownLeftMs <= 0;
|
||||||
|
const cooldownSeconds = Math.ceil(cooldownLeftMs / 1000);
|
||||||
|
|
||||||
|
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 giliran 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>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="rounded-md border border-amber-300 bg-amber-50 px-4 py-3 text-left text-sm text-amber-950"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
<p className="font-semibold">Anda sudah mempunyai nombor giliran.</p>
|
||||||
|
<p className="mt-1 text-amber-900">
|
||||||
|
Sila tunggu sehingga nombor anda dipanggil.
|
||||||
|
</p>
|
||||||
|
{!canGetAnotherTicket ? (
|
||||||
|
<p className="mt-2 font-medium tabular-nums text-amber-950">
|
||||||
|
Tiket lain boleh diambil dalam {cooldownSeconds}s
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={resetFlow}
|
||||||
|
disabled={!canGetAnotherTicket || loading}
|
||||||
|
aria-disabled={!canGetAnotherTicket || loading}
|
||||||
|
className={
|
||||||
|
canGetAnotherTicket
|
||||||
|
? "w-full rounded-md border border-zinc-300 bg-white px-4 py-3 text-sm font-medium text-zinc-700 transition hover:border-zinc-500 hover:bg-zinc-50 disabled:opacity-60"
|
||||||
|
: "w-full cursor-not-allowed rounded-md bg-zinc-200 px-4 py-3 text-sm font-medium text-zinc-500"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{canGetAnotherTicket
|
||||||
|
? "Dapatkan nombor giliran lain"
|
||||||
|
: `Tunggu ${cooldownSeconds}s…`}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { SERVER_URL } from "./constants";
|
||||||
|
|
||||||
|
export type Tenant = {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
welcomeMessage: string;
|
||||||
|
font: string | null;
|
||||||
|
logo?: { id: number; base64Logo: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Branch = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
tellerStations: { id: number; name: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Service = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Ticket = {
|
||||||
|
id: number;
|
||||||
|
number: string;
|
||||||
|
createdAt: string;
|
||||||
|
service: Service;
|
||||||
|
branch: Branch;
|
||||||
|
station: { id: number; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TicketResponse = {
|
||||||
|
ticket: Ticket;
|
||||||
|
stations: { id: number; name: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const response = await fetch(`${SERVER_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Request failed (${response.status}) for ${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTenant(code: string) {
|
||||||
|
return request<Tenant>(`/api/v1/tenants/${encodeURIComponent(code)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBranches(tenantCode: string) {
|
||||||
|
return request<Branch[]>(
|
||||||
|
`/api/v1/branches/${encodeURIComponent(tenantCode)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBranchServices(tenantCode: string, branchId: number) {
|
||||||
|
return request<Service[]>(
|
||||||
|
`/api/v1/branches/${encodeURIComponent(tenantCode)}/${branchId}/services`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTicket(input: {
|
||||||
|
branchId: number;
|
||||||
|
serviceId: number;
|
||||||
|
deviceToken: string;
|
||||||
|
}) {
|
||||||
|
return request<TicketResponse>("/api/v1/tickets", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTicketById(ticketId: number | string) {
|
||||||
|
return request<Ticket>(`/api/v1/tickets/${ticketId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTicketsForDevice(deviceToken: string) {
|
||||||
|
return request<TicketResponse[]>(
|
||||||
|
`/api/v1/tickets/devices/${encodeURIComponent(deviceToken)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const DEFAULT_QR_SECRET = "qms-branch-qr-v1";
|
||||||
|
|
||||||
|
function getQrSecret() {
|
||||||
|
return process.env.QR_TOKEN_SECRET || DEFAULT_QR_SECRET;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64Url(bytes: Uint8Array) {
|
||||||
|
let binary = "";
|
||||||
|
bytes.forEach((byte) => {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
});
|
||||||
|
return btoa(binary)
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromBase64Url(token: string) {
|
||||||
|
const padded = token.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
const padLength = (4 - (padded.length % 4)) % 4;
|
||||||
|
const base64 = padded + "=".repeat(padLength);
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i += 1) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function xorBytes(bytes: Uint8Array, secret: string) {
|
||||||
|
const key = new TextEncoder().encode(secret);
|
||||||
|
return bytes.map((byte, index) => byte ^ key[index % key.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BranchQrPayload = {
|
||||||
|
tenantCode: string;
|
||||||
|
branchId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function encodeBranchQrToken(
|
||||||
|
tenantCode: string,
|
||||||
|
branchId: number,
|
||||||
|
secret = getQrSecret()
|
||||||
|
) {
|
||||||
|
const plain = `v1:${tenantCode.trim().toUpperCase()}:${Number(branchId)}`;
|
||||||
|
const plainBytes = new TextEncoder().encode(plain);
|
||||||
|
return toBase64Url(xorBytes(plainBytes, secret));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeBranchQrToken(
|
||||||
|
token: string,
|
||||||
|
secret = getQrSecret()
|
||||||
|
): BranchQrPayload | null {
|
||||||
|
try {
|
||||||
|
const plain = new TextDecoder().decode(
|
||||||
|
xorBytes(fromBase64Url(token), secret)
|
||||||
|
);
|
||||||
|
const match = /^v1:([^:]+):(\d+)$/.exec(plain);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
tenantCode: match[1],
|
||||||
|
branchId: Number(match[2]),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const SERVER_URL = process.env.NEXT_PUBLIC_API_URL;
|
||||||
|
|
||||||
|
export const DEVICE_TOKEN_KEY = "qms_device_token";
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { DEVICE_TOKEN_KEY } from "./constants";
|
||||||
|
|
||||||
|
export function getDeviceToken(): string {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = window.localStorage.getItem(DEVICE_TOKEN_KEY);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token =
|
||||||
|
typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||||
|
? crypto.randomUUID()
|
||||||
|
: `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
|
window.localStorage.setItem(DEVICE_TOKEN_KEY, token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
devIndicators: false,
|
||||||
|
output: "standalone",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "customer-app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --webpack",
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 998 B |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |